├── .all-contributorsrc ├── .env.example ├── .eslintrc.json ├── .github ├── dependabot.yml └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── .prettierrc ├── .tina ├── __generated__ │ ├── .gitignore │ ├── _graphql.json │ ├── _lookup.json │ ├── _schema.json │ ├── frags.gql │ ├── queries.gql │ ├── schema.gql │ └── types.ts └── schema.ts ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── components ├── Accordion.tsx ├── ArticleCard.tsx ├── ArticleImage.tsx ├── AutofitGrid.tsx ├── BasicCard.tsx ├── BasicSection.tsx ├── Button.tsx ├── ButtonGroup.tsx ├── ClientOnly.tsx ├── CloseIcon.tsx ├── Code.tsx ├── Collapse.tsx ├── ColorSwitcher.tsx ├── Container.tsx ├── Drawer.tsx ├── Footer.tsx ├── GlobalStyles.tsx ├── HamburgerIcon.tsx ├── HeroIllustation.tsx ├── Icon.tsx ├── Input.tsx ├── Link.tsx ├── Logo.tsx ├── MDXRichText.tsx ├── MailSentState.tsx ├── Navbar.tsx ├── NavigationDrawer.tsx ├── NewsletterModal.tsx ├── NotFoundIllustration.tsx ├── OverTitle.tsx ├── Overlay.tsx ├── Page.tsx ├── PricingCard.tsx ├── Quote.tsx ├── RichText.tsx ├── SectionTitle.tsx ├── Separator.tsx ├── Spacer.tsx ├── ThreeLayersCircle.tsx ├── WaveCta.tsx └── YoutubeVideo.tsx ├── contexts └── newsletter-modal.context.tsx ├── env.ts ├── hooks ├── useClipboard.ts ├── useEscKey.ts ├── useResizeObserver.ts └── useScrollPosition.ts ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── 404.tsx ├── _app.tsx ├── _document.tsx ├── admin │ └── [[...tina]].tsx ├── api │ └── sendEmail.ts ├── blog │ ├── [slug].tsx │ └── index.tsx ├── contact.tsx ├── cookies-policy.tsx ├── features.tsx ├── index.tsx ├── pricing.tsx └── privacy-policy.tsx ├── posts ├── test-article-2.mdx ├── test-article-3.mdx ├── test-article-4.mdx ├── test-article-5.mdx ├── test-article-6.mdx └── test-article.mdx ├── public ├── demo-illustration-1.svg ├── demo-illustration-2.svg ├── demo-illustration-3.png ├── demo-illustration-4.png ├── demo-illustration-5.png ├── favicon.ico ├── grid-icons │ ├── asset-1.svg │ ├── asset-2.svg │ ├── asset-3.svg │ ├── asset-4.svg │ ├── asset-5.svg │ ├── asset-6.svg │ ├── asset-7.svg │ ├── asset-8.svg │ └── asset-9.svg ├── partners │ ├── logoipsum-logo-1.svg │ ├── logoipsum-logo-2.svg │ ├── logoipsum-logo-3.svg │ ├── logoipsum-logo-4.svg │ ├── logoipsum-logo-5.svg │ ├── logoipsum-logo-6.svg │ └── logoipsum-logo-7.svg ├── play-icon.svg ├── posts │ └── test-article │ │ ├── example-image-1.jpeg │ │ └── example-image-2.png ├── prism-theme.css ├── testimonials │ ├── author-photo-1.jpeg │ ├── author-photo-2.jpeg │ ├── author-photo-3.jpeg │ ├── company-logo-1.svg │ ├── company-logo-2.svg │ └── company-logo-3.svg └── vercel.svg ├── renovate.json ├── tsconfig.json ├── types.ts ├── utils ├── formatDate.ts ├── media.ts ├── postsFetcher.ts └── readTime.ts ├── views ├── ContactPage │ ├── FormSection.tsx │ └── InformationSection.tsx ├── HomePage │ ├── Cta.tsx │ ├── Features.tsx │ ├── FeaturesGallery.tsx │ ├── Hero.tsx │ ├── Partners.tsx │ ├── ScrollableBlogPosts.tsx │ └── Testimonials.tsx ├── PricingPage │ ├── FaqSection.tsx │ └── PricingTablesSection.tsx └── SingleArticlePage │ ├── Header.tsx │ ├── MetadataHead.tsx │ ├── OpenGraphHead.tsx │ ├── ShareWidget.tsx │ └── StructuredDataHead.tsx └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "next-saas-starter", 3 | "projectOwner": "Blazity", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "imageSize": 64, 10 | "commit": true, 11 | "commitConvention": "none", 12 | "contributors": [ 13 | { 14 | "login": "bmstefanski", 15 | "name": "Bart Stefanski", 16 | "avatar_url": "https://avatars.githubusercontent.com/u/28964599?v=4", 17 | "profile": "https://bstefanski.com/", 18 | "contributions": [ 19 | "code" 20 | ] 21 | }, 22 | { 23 | "login": "ilasota", 24 | "name": "Igor Lasota", 25 | "avatar_url": "https://avatars.githubusercontent.com/u/34578189?v=4", 26 | "profile": "https://github.com/ilasota", 27 | "contributions": [ 28 | "code" 29 | ] 30 | }, 31 | { 32 | "login": "jbryn", 33 | "name": "Jan Bryński", 34 | "avatar_url": "https://avatars.githubusercontent.com/u/52970664?v=4", 35 | "profile": "https://github.com/jbryn", 36 | "contributions": [ 37 | "code" 38 | ] 39 | }, 40 | { 41 | "login": "logan-anderson", 42 | "name": "Logan Anderson", 43 | "avatar_url": "https://avatars.githubusercontent.com/u/43075109?v=4", 44 | "profile": "https://www.logana.dev/", 45 | "contributions": [ 46 | "code", 47 | "doc", 48 | "mentoring" 49 | ] 50 | }, 51 | { 52 | "login": "fdukat", 53 | "name": "Filip Dukat", 54 | "avatar_url": "https://avatars.githubusercontent.com/u/87642690?v=4", 55 | "profile": "https://github.com/fdukat", 56 | "contributions": [ 57 | "doc" 58 | ] 59 | } 60 | ], 61 | "contributorsPerLine": 7 62 | } 63 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | SENDGRID_API_KEY= 2 | NEXT_PUBLIC_TINA_CLIENT_ID= 3 | NEXT_PUBLIC_EDIT_BRANCH="master" 4 | NEXT_PUBLIC_ORGANIZATION_NAME= 5 | NEXT_PUBLIC_USE_LOCAL_CLIENT="" -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": ["react-app", "prettier", "plugin:react/recommended", "next/core-web-vitals"], 4 | "env": { 5 | "es6": true 6 | }, 7 | "parserOptions": { 8 | "sourceType": "module" 9 | }, 10 | "rules": { 11 | "react/prop-types": 0, 12 | "react/react-in-jsx-scope": 0, 13 | "react/display-name": 0, 14 | "no-unused-vars": 0, 15 | "sort-imports": ["error", { "ignoreCase": true, "ignoreDeclarationSort": true }], 16 | "import/order": [ 17 | 1, 18 | { 19 | "groups": ["external", "builtin", "internal", "sibling", "parent", "index"], 20 | "pathGroups": [ 21 | { "pattern": "env", "group": "internal" }, 22 | { "pattern": "types", "group": "internal" }, 23 | { "pattern": "components/**", "group": "internal" }, 24 | { "pattern": "contexts/**", "group": "internal" }, 25 | { "pattern": "hooks/**", "group": "internal" }, 26 | { "pattern": "pages/**", "group": "internal" }, 27 | { "pattern": "views/**", "group": "internal" }, 28 | { "pattern": "utils/**", "group": "internal" }, 29 | { "pattern": "public/**", "group": "internal", "position": "after" }, 30 | { "pattern": "posts/**", "group": "internal", "position": "after" } 31 | ], 32 | "pathGroupsExcludedImportTypes": ["internal"], 33 | "alphabetize": { 34 | "order": "asc", 35 | "caseInsensitive": true 36 | } 37 | } 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ master ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ master ] 20 | schedule: 21 | - cron: '29 1 * * 5' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 52 | 53 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 54 | # If this step fails, then you should remove it and run the build manually (see below) 55 | - name: Autobuild 56 | uses: github/codeql-action/autobuild@v2 57 | 58 | # ℹ️ Command-line programs to run using the OS shell. 59 | # 📚 https://git.io/JvXDl 60 | 61 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 62 | # and modify them (or add more) to build your code if your project 63 | # uses a compiled language 64 | 65 | #- run: | 66 | # make bootstrap 67 | # make release 68 | 69 | - name: Perform CodeQL Analysis 70 | uses: github/codeql-action/analyze@v2 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /.tina/__generated__/.gitignore: -------------------------------------------------------------------------------- 1 | db -------------------------------------------------------------------------------- /.tina/__generated__/_lookup.json: -------------------------------------------------------------------------------- 1 | { 2 | "DocumentConnection": { 3 | "type": "DocumentConnection", 4 | "resolveType": "multiCollectionDocumentList", 5 | "collections": [ 6 | "posts" 7 | ] 8 | }, 9 | "Node": { 10 | "type": "Node", 11 | "resolveType": "nodeDocument" 12 | }, 13 | "DocumentNode": { 14 | "type": "DocumentNode", 15 | "resolveType": "multiCollectionDocument", 16 | "createDocument": "create", 17 | "updateDocument": "update" 18 | }, 19 | "PostsDocument": { 20 | "type": "PostsDocument", 21 | "resolveType": "collectionDocument", 22 | "collection": "posts", 23 | "createPostsDocument": "create", 24 | "updatePostsDocument": "update" 25 | }, 26 | "PostsConnection": { 27 | "type": "PostsConnection", 28 | "resolveType": "collectionDocumentList", 29 | "collection": "posts" 30 | } 31 | } -------------------------------------------------------------------------------- /.tina/__generated__/frags.gql: -------------------------------------------------------------------------------- 1 | fragment PostsParts on Posts { 2 | title 3 | description 4 | date 5 | tags 6 | imageUrl 7 | body 8 | } 9 | -------------------------------------------------------------------------------- /.tina/__generated__/queries.gql: -------------------------------------------------------------------------------- 1 | query getPostsDocument($relativePath: String!) { 2 | getPostsDocument(relativePath: $relativePath) { 3 | sys { 4 | filename 5 | basename 6 | breadcrumbs 7 | path 8 | relativePath 9 | extension 10 | } 11 | id 12 | data { 13 | ...PostsParts 14 | } 15 | } 16 | } 17 | 18 | query getPostsList { 19 | getPostsList { 20 | totalCount 21 | edges { 22 | node { 23 | id 24 | sys { 25 | filename 26 | basename 27 | breadcrumbs 28 | path 29 | relativePath 30 | extension 31 | } 32 | data { 33 | ...PostsParts 34 | } 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /.tina/__generated__/schema.gql: -------------------------------------------------------------------------------- 1 | # DO NOT MODIFY THIS FILE. This file is automatically generated by Tina 2 | """References another document, used as a foreign key""" 3 | scalar Reference 4 | 5 | """""" 6 | scalar JSON 7 | 8 | type SystemInfo { 9 | filename: String! 10 | basename: String! 11 | breadcrumbs(excludeExtension: Boolean): [String!]! 12 | path: String! 13 | relativePath: String! 14 | extension: String! 15 | template: String! 16 | collection: Collection! 17 | } 18 | 19 | type PageInfo { 20 | hasPreviousPage: Boolean! 21 | hasNextPage: Boolean! 22 | startCursor: String! 23 | endCursor: String! 24 | } 25 | 26 | """""" 27 | interface Node { 28 | id: ID! 29 | } 30 | 31 | """""" 32 | interface Document { 33 | sys: SystemInfo 34 | id: ID! 35 | form: JSON! 36 | values: JSON! 37 | } 38 | 39 | """A relay-compliant pagination connection""" 40 | interface Connection { 41 | totalCount: Int! 42 | } 43 | 44 | type Query { 45 | getCollection(collection: String): Collection! 46 | getCollections: [Collection!]! 47 | node(id: String): Node! 48 | getDocument(collection: String, relativePath: String): DocumentNode! 49 | getDocumentList(before: String, after: String, first: Int, last: Int): DocumentConnection! 50 | getDocumentFields: JSON! 51 | getPostsDocument(relativePath: String): PostsDocument! 52 | getPostsList(before: String, after: String, first: Int, last: Int): PostsConnection! 53 | } 54 | 55 | type DocumentConnectionEdges { 56 | cursor: String 57 | node: DocumentNode 58 | } 59 | 60 | type DocumentConnection implements Connection { 61 | pageInfo: PageInfo 62 | totalCount: Int! 63 | edges: [DocumentConnectionEdges] 64 | } 65 | 66 | type Collection { 67 | name: String! 68 | slug: String! 69 | label: String 70 | path: String! 71 | format: String 72 | matches: String 73 | templates: [JSON] 74 | fields: [JSON] 75 | documents(before: String, after: String, first: Int, last: Int): DocumentConnection! 76 | } 77 | 78 | union DocumentNode = PostsDocument 79 | 80 | type Posts { 81 | title: String 82 | description: String 83 | date: String 84 | tags: String 85 | imageUrl: String 86 | body: JSON 87 | } 88 | 89 | type PostsDocument implements Node & Document { 90 | id: ID! 91 | sys: SystemInfo! 92 | data: Posts! 93 | form: JSON! 94 | values: JSON! 95 | dataJSON: JSON! 96 | } 97 | 98 | type PostsConnectionEdges { 99 | cursor: String 100 | node: PostsDocument 101 | } 102 | 103 | type PostsConnection implements Connection { 104 | pageInfo: PageInfo 105 | totalCount: Int! 106 | edges: [PostsConnectionEdges] 107 | } 108 | 109 | type Mutation { 110 | addPendingDocument(collection: String!, relativePath: String!, template: String): DocumentNode! 111 | updateDocument(collection: String, relativePath: String!, params: DocumentMutation!): DocumentNode! 112 | createDocument(collection: String, relativePath: String!, params: DocumentMutation!): DocumentNode! 113 | updatePostsDocument(relativePath: String!, params: PostsMutation!): PostsDocument! 114 | createPostsDocument(relativePath: String!, params: PostsMutation!): PostsDocument! 115 | } 116 | 117 | input DocumentMutation { 118 | posts: PostsMutation 119 | } 120 | 121 | input PostsMutation { 122 | title: String 123 | description: String 124 | date: String 125 | tags: String 126 | imageUrl: String 127 | body: JSON 128 | } 129 | 130 | schema { 131 | query: Query 132 | mutation: Mutation 133 | } 134 | -------------------------------------------------------------------------------- /.tina/schema.ts: -------------------------------------------------------------------------------- 1 | import { defineSchema } from '@tinacms/cli'; 2 | 3 | export default defineSchema({ 4 | collections: [ 5 | { 6 | label: 'Blog Posts', 7 | name: 'posts', 8 | path: 'posts', 9 | fields: [ 10 | { 11 | type: 'string', 12 | label: 'Title', 13 | name: 'title', 14 | }, 15 | { 16 | type: 'string', 17 | label: 'Description', 18 | name: 'description', 19 | }, 20 | { 21 | type: 'string', 22 | label: 'Date', 23 | name: 'date', 24 | }, 25 | { 26 | type: 'string', 27 | label: 'Tags', 28 | name: 'tags', 29 | }, 30 | { 31 | type: 'string', 32 | label: 'Image URL', 33 | name: 'imageUrl', 34 | }, 35 | { 36 | type: 'rich-text', 37 | label: 'Blog Post Body', 38 | name: 'body', 39 | isBody: true, 40 | templates: [ 41 | { 42 | name: 'Quote', 43 | label: 'Quote', 44 | fields: [ 45 | { 46 | type: 'string', 47 | name: 'content', 48 | label: 'Content', 49 | }, 50 | { 51 | type: 'string', 52 | name: 'author', 53 | label: 'Author', 54 | }, 55 | { 56 | type: 'string', 57 | name: 'cite', 58 | label: 'Cite', 59 | }, 60 | ], 61 | }, 62 | { 63 | name: 'ArticleImage', 64 | label: 'ArticleImage', 65 | fields: [ 66 | { 67 | type: 'string', 68 | name: 'src', 69 | label: 'Src', 70 | }, 71 | { 72 | type: 'string', 73 | name: 'caption', 74 | label: 'Caption', 75 | }, 76 | ], 77 | }, 78 | { 79 | name: 'Code', 80 | label: 'Code', 81 | fields: [ 82 | { 83 | type: 'string', 84 | name: 'code', 85 | label: 'Code', 86 | }, 87 | { 88 | type: 'string', 89 | name: 'language', 90 | label: 'Language', 91 | }, 92 | { 93 | type: 'string', 94 | name: 'selectedLines', 95 | label: 'Selected Lines', 96 | }, 97 | { 98 | type: 'boolean', 99 | name: 'withCopyButton', 100 | label: 'With Copy Button', 101 | }, 102 | { 103 | type: 'boolean', 104 | name: 'withLineNumbers', 105 | label: 'With Line Numbers', 106 | }, 107 | { 108 | type: 'string', 109 | name: 'caption', 110 | label: 'Caption', 111 | }, 112 | ], 113 | }, 114 | { 115 | name: 'h2', 116 | label: 'H2', 117 | inline: true, 118 | fields: [], 119 | }, 120 | { 121 | name: 'h3', 122 | label: 'H3', 123 | inline: true, 124 | fields: [], 125 | }, 126 | { 127 | name: 'br', 128 | label: 'BR', 129 | inline: true, 130 | fields: [], 131 | }, 132 | { 133 | name: 'p', 134 | label: 'P', 135 | inline: true, 136 | fields: [], 137 | }, 138 | ], 139 | }, 140 | ], 141 | }, 142 | ], 143 | }); 144 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | } 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Blazity 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /components/Accordion.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { media } from 'utils/media'; 4 | import Collapse from './Collapse'; 5 | import RichText from './RichText'; 6 | 7 | interface AccordionProps { 8 | title: string; 9 | isOpen?: boolean; 10 | } 11 | 12 | export default function Accordion({ title, isOpen, children }: PropsWithChildren) { 13 | const [hasCollapsed, setHasCollapsed] = useState(!isOpen); 14 | const isActive = !hasCollapsed; 15 | return ( 16 | setHasCollapsed((prev) => !prev)}> 17 | 18 | {title} 19 | 20 | 29 | 30 | 31 | 32 | 33 | {children} 34 | 35 | 36 | 37 | ); 38 | } 39 | 40 | const Title = styled.h3` 41 | font-size: 2rem; 42 | width: 90%; 43 | overflow: hidden; 44 | text-overflow: ellipsis; 45 | display: -webkit-box; 46 | -webkit-box-orient: vertical; 47 | -webkit-line-clamp: 1; 48 | `; 49 | 50 | const TitleWrapper = styled.div` 51 | display: flex; 52 | justify-content: space-between; 53 | align-items: center; 54 | `; 55 | 56 | const Icon = styled.div<{ isActive: boolean }>` 57 | width: 2.4rem; 58 | transition: transform 0.3s; 59 | transform: rotateZ(${(p) => (p.isActive ? 180 : 0)}deg); 60 | `; 61 | 62 | const Description = styled.div` 63 | margin-top: 2.5rem; 64 | font-size: 1.6rem; 65 | font-weight: normal; 66 | `; 67 | 68 | const AccordionWrapper = styled.div` 69 | display: flex; 70 | flex-direction: column; 71 | padding: 2rem 1.5rem; 72 | background: rgb(var(--cardBackground)); 73 | box-shadow: var(--shadow-md); 74 | cursor: pointer; 75 | border-radius: 0.6rem; 76 | transition: opacity 0.2s; 77 | 78 | ${media('<=desktop')} { 79 | width: 100%; 80 | } 81 | `; 82 | -------------------------------------------------------------------------------- /components/ArticleCard.tsx: -------------------------------------------------------------------------------- 1 | import NextImage from 'next/image'; 2 | import NextLink from 'next/link'; 3 | import styled from 'styled-components'; 4 | import { media } from 'utils/media'; 5 | 6 | export interface ArticleCardProps { 7 | title: string; 8 | slug: string; 9 | imageUrl: string; 10 | description: string; 11 | } 12 | 13 | export default function ArticleCard({ title, slug, imageUrl, description }: ArticleCardProps) { 14 | return ( 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {title} 23 | {description} 24 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | const ArticleCardWrapper = styled.a` 32 | display: flex; 33 | flex-direction: column; 34 | height: 45rem; 35 | max-width: 35rem; 36 | overflow: hidden; 37 | text-decoration: none; 38 | border-radius: 0.6rem; 39 | background: rgb(var(--cardBackground)); 40 | cursor: pointer; 41 | color: rgb(var(--text)); 42 | `; 43 | 44 | const HoverEffectContainer = styled.div` 45 | transition: transform 0.3s; 46 | backface-visibility: hidden; 47 | will-change: transform; 48 | 49 | &:hover { 50 | border-radius: 0.6rem; 51 | overflow: hidden; 52 | transform: scale(1.025); 53 | } 54 | `; 55 | 56 | const ImageContainer = styled.div` 57 | position: relative; 58 | height: 20rem; 59 | 60 | &:before { 61 | display: block; 62 | content: ''; 63 | width: 100%; 64 | padding-top: calc((9 / 16) * 100%); 65 | } 66 | 67 | & > div { 68 | position: absolute; 69 | top: 0; 70 | right: 0; 71 | bottom: 0; 72 | left: 0; 73 | } 74 | 75 | ${media('<=desktop')} { 76 | width: 100%; 77 | } 78 | `; 79 | 80 | const Content = styled.div` 81 | padding: 0 2rem; 82 | 83 | & > * { 84 | margin-top: 2rem; 85 | } 86 | `; 87 | 88 | const Title = styled.h4` 89 | font-size: 1.8rem; 90 | 91 | overflow: hidden; 92 | text-overflow: ellipsis; 93 | display: -webkit-box; 94 | -webkit-box-orient: vertical; 95 | -webkit-line-clamp: 2; 96 | `; 97 | 98 | const Description = styled.p` 99 | font-size: 1.6rem; 100 | 101 | overflow: hidden; 102 | text-overflow: ellipsis; 103 | display: -webkit-box; 104 | opacity: 0.6; 105 | -webkit-box-orient: vertical; 106 | -webkit-line-clamp: 5; 107 | `; 108 | -------------------------------------------------------------------------------- /components/ArticleImage.tsx: -------------------------------------------------------------------------------- 1 | import NextImage, { ImageProps } from 'next/image'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | interface ArticleImageProps extends ImageProps { 6 | src: string; 7 | caption?: string; 8 | } 9 | 10 | export default function ArticleImage({ src, caption, ...rest }: ArticleImageProps) { 11 | return ( 12 | 13 | 14 | 23 | 24 | {caption} 25 | 26 | ); 27 | } 28 | 29 | const ImageWrapper = styled.div` 30 | position: relative; 31 | max-width: 90rem; 32 | border-radius: 0.6rem; 33 | overflow: hidden; 34 | 35 | &::before { 36 | float: left; 37 | padding-top: 56.25%; 38 | content: ''; 39 | } 40 | 41 | &::after { 42 | display: block; 43 | content: ''; 44 | clear: both; 45 | } 46 | `; 47 | 48 | const Wrapper = styled.div` 49 | display: flex; 50 | flex-direction: column; 51 | width: 100%; 52 | 53 | &:not(:last-child) { 54 | margin-bottom: 3rem; 55 | } 56 | `; 57 | 58 | const Caption = styled.small` 59 | display: block; 60 | font-size: 1.4rem; 61 | text-align: center; 62 | margin-top: 1rem; 63 | `; 64 | -------------------------------------------------------------------------------- /components/AutofitGrid.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const AutofitGrid = styled.div` 4 | --autofit-grid-item-size: 30rem; 5 | 6 | display: grid; 7 | grid-gap: 2rem; 8 | grid-template-columns: repeat(auto-fit, minmax(var(--autofit-grid-item-size), 1fr)); 9 | margin: 0 auto; 10 | `; 11 | 12 | export default AutofitGrid; 13 | -------------------------------------------------------------------------------- /components/BasicCard.tsx: -------------------------------------------------------------------------------- 1 | import NextImage from 'next/image'; 2 | import styled from 'styled-components'; 3 | 4 | interface BasicCardProps { 5 | title: string; 6 | description: string; 7 | imageUrl: string; 8 | } 9 | 10 | export default function BasicCard({ title, description, imageUrl }: BasicCardProps) { 11 | return ( 12 | 13 | 14 | {title} 15 | {description} 16 | 17 | ); 18 | } 19 | 20 | const Card = styled.div` 21 | display: flex; 22 | padding: 2.5rem; 23 | background: rgb(var(--cardBackground)); 24 | box-shadow: var(--shadow-md); 25 | flex-direction: column; 26 | justify-content: center; 27 | align-items: center; 28 | text-align: center; 29 | width: 100%; 30 | border-radius: 0.6rem; 31 | color: rgb(var(--text)); 32 | font-size: 1.6rem; 33 | 34 | & > *:not(:first-child) { 35 | margin-top: 1rem; 36 | } 37 | `; 38 | 39 | const Title = styled.div` 40 | font-weight: bold; 41 | `; 42 | 43 | const Description = styled.div` 44 | opacity: 0.6; 45 | `; 46 | -------------------------------------------------------------------------------- /components/BasicSection.tsx: -------------------------------------------------------------------------------- 1 | import NextImage from 'next/image'; 2 | import React, { PropsWithChildren } from 'react'; 3 | import styled from 'styled-components'; 4 | import { media } from 'utils/media'; 5 | import Container from './Container'; 6 | import OverTitle from './OverTitle'; 7 | import RichText from './RichText'; 8 | 9 | export interface BasicSectionProps { 10 | imageUrl: string; 11 | title: string; 12 | overTitle: string; 13 | reversed?: boolean; 14 | } 15 | 16 | export default function BasicSection({ imageUrl, title, overTitle, reversed, children }: PropsWithChildren) { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | {overTitle} 24 | {title} 25 | {children} 26 | 27 | 28 | ); 29 | } 30 | 31 | const Title = styled.h1` 32 | font-size: 5.2rem; 33 | font-weight: bold; 34 | line-height: 1.1; 35 | margin-bottom: 4rem; 36 | letter-spacing: -0.03em; 37 | 38 | ${media('<=tablet')} { 39 | font-size: 4.6rem; 40 | margin-bottom: 2rem; 41 | } 42 | `; 43 | 44 | const CustomOverTitle = styled(OverTitle)` 45 | margin-bottom: 2rem; 46 | `; 47 | 48 | const ImageContainer = styled.div` 49 | flex: 1; 50 | 51 | position: relative; 52 | &:before { 53 | display: block; 54 | content: ''; 55 | width: 100%; 56 | padding-top: calc((9 / 16) * 100%); 57 | } 58 | 59 | & > div { 60 | position: absolute; 61 | top: 0; 62 | right: 0; 63 | bottom: 0; 64 | left: 0; 65 | } 66 | 67 | ${media('<=desktop')} { 68 | width: 100%; 69 | } 70 | `; 71 | 72 | const ContentContainer = styled.div` 73 | flex: 1; 74 | `; 75 | 76 | type Props = Pick; 77 | const BasicSectionWrapper = styled(Container)` 78 | display: flex; 79 | align-items: center; 80 | flex-direction: ${(p: Props) => (p.reversed ? 'row-reverse' : 'row')}; 81 | 82 | ${ImageContainer} { 83 | margin: ${(p: Props) => (p.reversed ? '0 0 0 5rem' : '0 5rem 0 0')}; 84 | } 85 | 86 | ${media('<=desktop')} { 87 | flex-direction: column; 88 | 89 | ${ImageContainer} { 90 | margin: 0 0 2.5rem 0; 91 | } 92 | } 93 | `; 94 | -------------------------------------------------------------------------------- /components/Button.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | type ButtonProps = PropsWithChildren<{ transparent?: boolean }>; 5 | 6 | const Button = styled.a` 7 | border: none; 8 | background: none; 9 | display: inline-block; 10 | text-decoration: none; 11 | text-align: center; 12 | background: ${(p) => (p.transparent ? 'transparent' : 'rgb(var(--primary))')}; 13 | padding: 1.75rem 2.25rem; 14 | font-size: 1.2rem; 15 | color: ${(p) => (p.transparent ? 'rgb(var(--text))' : 'rgb(var(--textSecondary))')}; 16 | text-transform: uppercase; 17 | font-family: var(--font); 18 | font-weight: bold; 19 | border-radius: 0.4rem; 20 | border: ${(p) => (p.transparent ? 'none' : '2px solid rgb(var(--primary))')}; 21 | transition: transform 0.3s; 22 | backface-visibility: hidden; 23 | will-change: transform; 24 | cursor: pointer; 25 | 26 | span { 27 | margin-left: 2rem; 28 | } 29 | 30 | &:hover { 31 | transform: scale(1.025); 32 | } 33 | `; 34 | 35 | export default Button; 36 | -------------------------------------------------------------------------------- /components/ButtonGroup.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { media } from 'utils/media'; 3 | 4 | const ButtonGroup = styled.div` 5 | display: flex; 6 | flex-wrap: wrap; 7 | 8 | & > *:not(:last-child) { 9 | margin-right: 2rem; 10 | } 11 | 12 | ${media('<=tablet')} { 13 | & > * { 14 | width: 100%; 15 | } 16 | 17 | & > *:not(:last-child) { 18 | margin-bottom: 2rem; 19 | margin-right: 0rem; 20 | } 21 | } 22 | `; 23 | 24 | export default ButtonGroup; 25 | -------------------------------------------------------------------------------- /components/ClientOnly.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren, useEffect, useState } from 'react' 2 | 3 | export default function ClientOnly(props: PropsWithChildren) { 4 | const { children, ...rest } = props 5 | const [hasMounted, setHasMounted] = useState(false) 6 | useEffect(() => { 7 | setHasMounted(true) 8 | }, []) 9 | if (!hasMounted) return
10 | return <>{props.children} 11 | } 12 | -------------------------------------------------------------------------------- /components/CloseIcon.tsx: -------------------------------------------------------------------------------- 1 | import Icon, { IconProps } from './Icon' 2 | 3 | export default function CloseIcon(props: IconProps) { 4 | return ( 5 | 6 | 12 | 13 | ) 14 | } 15 | -------------------------------------------------------------------------------- /components/Code.tsx: -------------------------------------------------------------------------------- 1 | import Highlight, { defaultProps, Language } from 'prism-react-renderer'; 2 | import React from 'react'; 3 | import styled from 'styled-components'; 4 | import ClientOnly from 'components/ClientOnly'; 5 | import { useClipboard } from 'hooks/useClipboard'; 6 | 7 | export interface CodeProps { 8 | code: string; 9 | language?: Language; 10 | selectedLines?: number[]; 11 | withCopyButton?: boolean; 12 | withLineNumbers?: boolean; 13 | caption?: string; 14 | } 15 | 16 | export default function Code({ 17 | code, 18 | language = 'javascript', 19 | selectedLines = [], 20 | withCopyButton = true, 21 | withLineNumbers, 22 | caption, 23 | }: CodeProps) { 24 | const { copy, copied } = useClipboard({ 25 | copiedTimeout: 600, 26 | }); 27 | 28 | function handleCopyClick(code: string) { 29 | copy(code); 30 | } 31 | 32 | const copyButtonMarkup = ( 33 | 34 | handleCopyClick(code)}> 35 | 36 | 37 | 38 | ); 39 | 40 | return ( 41 | <> 42 | 43 | {({ className, style, tokens, getLineProps, getTokenProps }) => ( 44 | <> 45 | 46 | {withCopyButton && copyButtonMarkup} 47 |
 48 |                 {tokens.map((line, i) => {
 49 |                   const lineNumber = i + 1;
 50 |                   const isSelected = selectedLines.includes(lineNumber);
 51 |                   const lineProps = getLineProps({ line, key: i });
 52 |                   const className = lineProps.className + (isSelected ? ' selected-line' : '');
 53 | 
 54 |                   return (
 55 |                     
 56 |                       {withLineNumbers && {lineNumber}}
 57 |                       
 58 |                         {line.map((token, key) => (
 59 |                           
 60 |                         ))}
 61 |                       
 62 |                     
 63 |                   );
 64 |                 })}
 65 |               
66 |
67 | {caption && {caption}} 68 | 69 | )} 70 |
71 | 72 | ); 73 | } 74 | 75 | function CopyIcon() { 76 | return ( 77 | 78 | 82 | 83 | ); 84 | } 85 | 86 | const Caption = styled.small` 87 | position: relative; 88 | top: -2.2rem; 89 | word-break: break-word; 90 | font-size: 1.2rem; 91 | `; 92 | 93 | const CopyButton = styled.button<{ copied: boolean }>` 94 | position: absolute; 95 | border: none; 96 | top: 2.4rem; 97 | right: 2.4rem; 98 | visibility: hidden; 99 | background-color: rgba(var(--secondary), 0.1); 100 | cursor: pointer; 101 | width: 3rem; 102 | height: 3rem; 103 | line-height: normal; 104 | border-radius: 0.3rem; 105 | color: rgb(var(--text)); 106 | z-index: 1; 107 | line-height: 1; 108 | 109 | &::after { 110 | position: absolute; 111 | content: 'Copied'; 112 | visibility: ${(p) => (p.copied ? 'visible' : 'hidden')}; 113 | top: 0; 114 | left: -4rem; 115 | height: 3rem; 116 | font-weight: bold; 117 | border-radius: 0.3rem; 118 | line-height: 1.5; 119 | font-size: 1.4rem; 120 | padding: 0.5rem 1rem; 121 | color: rgb(var(--primary)); 122 | background-color: rgb(var(--secondary)); 123 | } 124 | 125 | &:hover { 126 | background-color: rgba(var(--secondary), 0.2); 127 | } 128 | `; 129 | 130 | const CodeWrapper = styled.div<{ language: string }>` 131 | position: relative; 132 | border-radius: 0.3em; 133 | margin-top: 4.5rem; 134 | transition: visibility 0.1s; 135 | font-size: 1.6rem; 136 | 137 | &:not(:last-child) { 138 | margin-bottom: 3rem; 139 | } 140 | 141 | &::after { 142 | position: absolute; 143 | height: 2.2em; 144 | content: '${(p) => p.language}'; 145 | right: 2.4rem; 146 | padding: 1.2rem; 147 | top: -2em; 148 | line-height: 1rem; 149 | border-radius: 0.3em; 150 | font-size: 1.5rem; 151 | text-transform: uppercase; 152 | background-color: inherit; 153 | font-weight: bold; 154 | text-align: center; 155 | } 156 | 157 | &:hover { 158 | ${CopyButton} { 159 | visibility: visible; 160 | } 161 | } 162 | `; 163 | 164 | const Pre = styled.pre` 165 | text-align: left; 166 | margin: 1em 0; 167 | padding: 0.5em; 168 | overflow: scroll; 169 | `; 170 | 171 | const Line = styled.div` 172 | display: flex; 173 | `; 174 | 175 | const LineNo = styled.span` 176 | display: table-cell; 177 | text-align: right; 178 | padding-right: 1em; 179 | user-select: none; 180 | opacity: 0.5; 181 | `; 182 | 183 | const LineContent = styled.span` 184 | display: table-cell; 185 | `; 186 | -------------------------------------------------------------------------------- /components/Collapse.tsx: -------------------------------------------------------------------------------- 1 | import { forwardRef, PropsWithChildren } from 'react'; 2 | import AnimateHeight from 'react-animate-height'; 3 | 4 | export interface CollapseProps { 5 | isOpen?: boolean; 6 | animateOpacity?: boolean; 7 | onAnimationStart?: () => void; 8 | onAnimationEnd?: () => void; 9 | duration?: number; 10 | easing?: string; 11 | startingHeight?: number | string; 12 | endingHeight?: number | string; 13 | } 14 | 15 | const Collapse = forwardRef>( 16 | ( 17 | { 18 | isOpen, 19 | animateOpacity = true, 20 | onAnimationStart, 21 | onAnimationEnd, 22 | duration, 23 | easing = 'ease', 24 | startingHeight = 0, 25 | endingHeight = 'auto', 26 | ...rest 27 | }, 28 | ref, 29 | ) => { 30 | return ( 31 | 43 |
44 | 45 | ); 46 | }, 47 | ); 48 | 49 | export default Collapse; 50 | -------------------------------------------------------------------------------- /components/ColorSwitcher.tsx: -------------------------------------------------------------------------------- 1 | import { ColorModeStyles, useColorModeValue, useColorSwitcher } from 'nextjs-color-mode'; 2 | import styled from 'styled-components'; 3 | 4 | export default function ColorSwitcher() { 5 | const { toggleTheme, colorMode } = useColorSwitcher(); 6 | 7 | const sunIcon = ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | 23 | const moonIcon = ( 24 | 25 | 29 | 30 | ); 31 | 32 | return {colorMode === 'light' ? moonIcon : sunIcon}; 33 | } 34 | 35 | const CustomButton = styled.button` 36 | display: flex; 37 | cursor: pointer; 38 | align-items: center; 39 | border: 0; 40 | width: 4rem; 41 | height: 4rem; 42 | background: transparent; 43 | 44 | svg { 45 | color: var(--logoColor); 46 | } 47 | `; 48 | -------------------------------------------------------------------------------- /components/Container.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Container = styled.div` 4 | position: relative; 5 | max-width: 130em; 6 | width: 100%; 7 | margin: 0 auto; 8 | padding: 0 2rem; 9 | `; 10 | 11 | export default Container; 12 | -------------------------------------------------------------------------------- /components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import * as OriginalDrawer from '@accessible/drawer' 2 | 3 | export default OriginalDrawer 4 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link'; 2 | import { FacebookIcon, LinkedinIcon, TwitterIcon } from 'react-share'; 3 | import styled from 'styled-components'; 4 | import Container from 'components/Container'; 5 | import { media } from 'utils/media'; 6 | 7 | type SingleFooterListItem = { title: string; href: string }; 8 | type FooterListItems = SingleFooterListItem[]; 9 | type SingleFooterList = { title: string; items: FooterListItems }; 10 | type FooterItems = SingleFooterList[]; 11 | 12 | const footerItems: FooterItems = [ 13 | { 14 | title: 'Company', 15 | items: [ 16 | { title: 'Privacy Policy', href: '/privacy-policy' }, 17 | { title: 'Cookies Policy', href: '/cookies-policy' }, 18 | ], 19 | }, 20 | { 21 | title: 'Product', 22 | items: [ 23 | { title: 'Features', href: '/features' }, 24 | { title: 'Something', href: '/something' }, 25 | { title: 'Something else', href: '/something-else' }, 26 | { title: 'And something else', href: '/and-something-else' }, 27 | ], 28 | }, 29 | { 30 | title: 'Knowledge', 31 | items: [ 32 | { title: 'Blog', href: '/blog' }, 33 | { title: 'Contact', href: '/contact' }, 34 | { title: 'FAQ', href: '/faq' }, 35 | { title: 'Help Center', href: '/help-center' }, 36 | ], 37 | }, 38 | { 39 | title: 'Something', 40 | items: [ 41 | { title: 'Features2', href: '/features2' }, 42 | { title: 'Something2', href: '/something2' }, 43 | { title: 'Something else2', href: '/something-else2' }, 44 | { title: 'And something else2', href: '/and-something-else2' }, 45 | ], 46 | }, 47 | ]; 48 | 49 | export default function Footer() { 50 | return ( 51 | 52 | 53 | 54 | {footerItems.map((singleItem) => ( 55 | 56 | ))} 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | © Copyright 2021 My Saas Startup 79 | 80 | 81 | 82 | ); 83 | } 84 | 85 | function FooterList({ title, items }: SingleFooterList) { 86 | return ( 87 | 88 | {title} 89 | {items.map((singleItem) => ( 90 | 91 | ))} 92 | 93 | ); 94 | } 95 | 96 | function ListItem({ title, href }: SingleFooterListItem) { 97 | return ( 98 | 99 | 100 | {title} 101 | 102 | 103 | ); 104 | } 105 | 106 | const FooterWrapper = styled.div` 107 | padding-top: 10rem; 108 | padding-bottom: 4rem; 109 | background: rgb(var(--secondary)); 110 | color: rgb(var(--textSecondary)); 111 | `; 112 | 113 | const ListContainer = styled.div` 114 | display: flex; 115 | flex-direction: row; 116 | flex-wrap: wrap; 117 | justify-content: space-between; 118 | `; 119 | 120 | const ListHeader = styled.p` 121 | font-weight: bold; 122 | font-size: 2.25rem; 123 | margin-bottom: 2.5rem; 124 | `; 125 | 126 | const ListWrapper = styled.div` 127 | display: flex; 128 | flex-direction: column; 129 | margin-bottom: 5rem; 130 | margin-right: 5rem; 131 | 132 | & > *:not(:first-child) { 133 | margin-top: 1rem; 134 | } 135 | 136 | ${media('<=tablet')} { 137 | flex: 0 40%; 138 | margin-right: 1.5rem; 139 | } 140 | 141 | ${media('<=phone')} { 142 | flex: 0 100%; 143 | margin-right: 0rem; 144 | } 145 | `; 146 | 147 | const ListItemWrapper = styled.p` 148 | font-size: 1.6rem; 149 | 150 | a { 151 | text-decoration: none; 152 | color: rgba(var(--textSecondary), 0.75); 153 | } 154 | `; 155 | 156 | const ShareBar = styled.div` 157 | & > *:not(:first-child) { 158 | margin-left: 1rem; 159 | } 160 | `; 161 | 162 | const Copyright = styled.p` 163 | font-size: 1.5rem; 164 | margin-top: 0.5rem; 165 | `; 166 | 167 | const BottomBar = styled.div` 168 | margin-top: 6rem; 169 | display: flex; 170 | justify-content: space-between; 171 | align-items: center; 172 | 173 | ${media('<=tablet')} { 174 | flex-direction: column; 175 | } 176 | `; 177 | -------------------------------------------------------------------------------- /components/GlobalStyles.tsx: -------------------------------------------------------------------------------- 1 | import { createGlobalStyle } from 'styled-components'; 2 | 3 | // default breakpoints 4 | // { 5 | // smallPhone: 320; 6 | // phone: 375; 7 | // tablet: 768; 8 | // desktop: 1024; 9 | // largeDesktop: 1440; 10 | // } 11 | 12 | export const GlobalStyle = createGlobalStyle` 13 | 14 | .next-light-theme { 15 | --background: 251,251,253; 16 | --secondBackground: 255,255,255; 17 | --text: 10,18,30; 18 | --textSecondary: 255,255,255; 19 | --primary: 22,115,255; 20 | --secondary: 10,18,30; 21 | --tertiary: 231,241,251; 22 | --cardBackground: 255,255,255; 23 | --inputBackground: 255,255,255; 24 | --navbarBackground: 255,255,255; 25 | --modalBackground: 251,251,253; 26 | --errorColor: 207,34,46; 27 | --logoColor: #243A5A; 28 | } 29 | 30 | .next-dark-theme { 31 | --background: 26,32,44; 32 | --secondBackground: 45,55,72; 33 | --text: 237,237,238; 34 | --textSecondary: 255,255,255; 35 | --primary: 22,115,255; 36 | --secondary: 10,18,30; 37 | --tertiary: 231,241,251; 38 | --cardBackground: 45,55,72; 39 | --inputBackground: 45,55,72; 40 | --navbarBackground: 45,55,72; 41 | --modalBackground: 26,32,44; 42 | --errorColor: 207,34,46; 43 | --logoColor: #fff; 44 | } 45 | 46 | :root { 47 | --font: 'Poppins', sans-serif; 48 | 49 | --shadow-md: 0 2px 4px 0 rgb(12 0 46 / 4%); 50 | --shadow-lg: 0 10px 14px 0 rgb(12 0 46 / 6%); 51 | 52 | --z-sticky: 7777; 53 | --z-navbar: 8888; 54 | --z-drawer: 9999; 55 | --z-modal: 9999; 56 | } 57 | 58 | /* Box sizing rules */ 59 | *, 60 | *::before, 61 | *::after { 62 | box-sizing: border-box; 63 | } 64 | 65 | /* Remove default margin */ 66 | body, 67 | h1, 68 | h2, 69 | h3, 70 | h4, 71 | p, 72 | figure, 73 | blockquote, 74 | dl, 75 | dd { 76 | margin: 0; 77 | } 78 | 79 | 80 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 81 | ul[role='list'], 82 | ol[role='list'] { 83 | list-style: none; 84 | } 85 | 86 | /* Set core root defaults */ 87 | html:focus-within { 88 | scroll-behavior: smooth; 89 | } 90 | 91 | html { 92 | -webkit-font-smoothing: antialiased; 93 | touch-action: manipulation; 94 | text-rendering: optimizelegibility; 95 | text-size-adjust: 100%; 96 | font-size: 62.5%; 97 | 98 | @media (max-width: 37.5em) { 99 | font-size: 50%; 100 | } 101 | 102 | @media (max-width: 48.0625em) { 103 | font-size: 55%; 104 | } 105 | 106 | @media (max-width: 56.25em) { 107 | font-size: 60%; 108 | } 109 | } 110 | 111 | /* Set core body defaults */ 112 | body { 113 | min-height: 100vh; 114 | text-rendering: optimizeSpeed; 115 | line-height: 1.5; 116 | font-family: var(--font); 117 | color: rgb(var(--text)); 118 | background: rgb(var(--background)); 119 | font-feature-settings: "kern"; 120 | } 121 | 122 | svg { 123 | color: rgb(var(--text)); 124 | } 125 | 126 | /* A elements that don't have a class get default styles */ 127 | a:not([class]) { 128 | text-decoration-skip-ink: auto; 129 | } 130 | 131 | /* Make images easier to work with */ 132 | img, 133 | picture { 134 | max-width: 100%; 135 | display: block; 136 | } 137 | 138 | /* Inherit fonts for inputs and buttons */ 139 | input, 140 | button, 141 | textarea, 142 | select { 143 | font: inherit; 144 | } 145 | 146 | /* Remove all animations, transitions and smooth scroll for people that prefer not to see them */ 147 | @media (prefers-reduced-motion: reduce) { 148 | html:focus-within { 149 | scroll-behavior: auto; 150 | } 151 | 152 | *, 153 | *::before, 154 | *::after { 155 | animation-duration: 0.01ms !important; 156 | animation-iteration-count: 1 !important; 157 | transition-duration: 0.01ms !important; 158 | scroll-behavior: auto !important; 159 | } 160 | 161 | }`; 162 | -------------------------------------------------------------------------------- /components/HamburgerIcon.tsx: -------------------------------------------------------------------------------- 1 | import Icon, { IconProps } from './Icon' 2 | 3 | export function HamburgerIcon(props: IconProps) { 4 | return ( 5 | 6 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React, { HTMLProps, Ref } from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | export type IconProps = HTMLProps & { _ref?: Ref }; 5 | 6 | export default function Icon({ _ref, ...rest }: any) { 7 | return ; 8 | } 9 | 10 | const IconWrapper = styled.button` 11 | border: none; 12 | background-color: transparent; 13 | width: 4rem; 14 | `; 15 | -------------------------------------------------------------------------------- /components/Input.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Input = styled.input` 4 | border: 1px solid rgb(var(--inputBackground)); 5 | background: rgb(var(--inputBackground)); 6 | border-radius: 0.6rem; 7 | font-size: 1.6rem; 8 | padding: 1.8rem; 9 | box-shadow: var(--shadow-md); 10 | /* color: rgb(var(--textSecondary)); */ 11 | 12 | &:focus { 13 | outline: none; 14 | box-shadow: var(--shadow-lg); 15 | } 16 | `; 17 | 18 | export default Input; 19 | -------------------------------------------------------------------------------- /components/Link.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link'; 2 | import { PropsWithChildren } from 'react'; 3 | import styled from 'styled-components'; 4 | 5 | export interface LinkProps { 6 | href: string; 7 | } 8 | 9 | export default function Link({ href, children }: PropsWithChildren) { 10 | return ( 11 | 12 | {children} 13 | 14 | ); 15 | } 16 | 17 | const Anchor = styled.a` 18 | display: inline; 19 | width: fit-content; 20 | text-decoration: none; 21 | 22 | background: linear-gradient(rgb(var(--primary)), rgb(var(--primary))); 23 | background-position: 0% 100%; 24 | background-repeat: no-repeat; 25 | background-size: 100% 0px; 26 | transition: 100ms; 27 | transition-property: background-size, text-decoration, color; 28 | color: rgb(var(--primary)); 29 | 30 | &:hover { 31 | background-size: 100% 100%; 32 | text-decoration: none; 33 | color: rgb(var(--background)); 34 | } 35 | 36 | &:active { 37 | color: rgb(var(--background)); 38 | background-size: 100% 100%; 39 | } 40 | `; 41 | -------------------------------------------------------------------------------- /components/MDXRichText.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Components, TinaMarkdown, TinaMarkdownContent } from 'tinacms/dist/rich-text'; 3 | import { media } from 'utils/media'; 4 | import ArticleImage from './ArticleImage'; 5 | import Code from './Code'; 6 | import Link from './Link'; 7 | import Quote from './Quote'; 8 | 9 | export default function RichText(props: { content: TinaMarkdownContent | TinaMarkdownContent[] }) { 10 | return ( 11 | 12 | } /> 13 | 14 | ); 15 | } 16 | 17 | const Container = styled.div` 18 | display: flex; 19 | ${'' /* Opting-out of margin-collapse */} 20 | 21 | flex-direction: column; 22 | width: 100%; 23 | 24 | section:not(:last-child) { 25 | margin-bottom: 3.8rem; 26 | } 27 | 28 | a { 29 | word-break: break-word; 30 | } 31 | 32 | ${media('<=desktop')} { 33 | .remark-highlight { 34 | width: 100%; 35 | overflow-x: auto; 36 | } 37 | } 38 | 39 | & > section, 40 | .footnotes { 41 | ${'' /* content-visibility: auto; */} 42 | } 43 | 44 | ol, 45 | ul { 46 | font-size: 1.8rem; 47 | line-height: 2.7rem; 48 | margin: 0; 49 | padding-left: 2.4rem; 50 | li { 51 | & > * { 52 | vertical-align: top; 53 | } 54 | } 55 | 56 | &:not(:last-child) { 57 | margin-bottom: 2.7rem; 58 | } 59 | } 60 | `; 61 | 62 | const Paragraph = styled.p` 63 | font-size: 1.8rem; 64 | line-height: 2.7rem; 65 | hanging-punctuation: first; 66 | 67 | &:not(:last-child) { 68 | margin-bottom: 2.7rem; 69 | } 70 | 71 | & + ul, 72 | & + li { 73 | margin-top: -1.5rem !important; 74 | } 75 | `; 76 | 77 | const SecondHeading = styled.h2` 78 | font-size: 2.5rem; 79 | line-height: 3.75rem; 80 | margin-bottom: 3.75rem; 81 | `; 82 | 83 | const ThirdHeading = styled.h3` 84 | font-size: 2.2rem; 85 | line-height: 3.4rem; 86 | margin-bottom: 3.4rem; 87 | `; 88 | 89 | const Break = styled.br` 90 | display: block; 91 | content: ''; 92 | margin: 0; 93 | height: 3rem; 94 | `; 95 | 96 | const TextHighlight = styled.code` 97 | display: inline-block; 98 | padding: 0 0.6rem; 99 | color: rgb(var(--textSecondary)); 100 | border-radius: 0.4rem; 101 | background-color: rgba(var(--primary), 0.8); 102 | font-size: 1.6rem; 103 | font-family: inherit; 104 | `; 105 | 106 | const components = { 107 | h2: SecondHeading, 108 | h3: ThirdHeading, 109 | p: Paragraph, 110 | br: Break, 111 | inlineCode: TextHighlight, 112 | Image: ArticleImage, 113 | Link, 114 | Code, 115 | Quote, 116 | ArticleImage, 117 | }; 118 | -------------------------------------------------------------------------------- /components/MailSentState.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default function MailSentState() { 4 | return ( 5 | 6 | 7 | 12 | 13 | 18 | 23 | 28 | 33 | 38 | 43 | 44 |

Mail successfully sent!

45 |
46 | ); 47 | } 48 | 49 | const Wrapper = styled.div` 50 | flex: 1; 51 | 52 | & > *:not(:first-child) { 53 | margin-top: 5rem; 54 | } 55 | 56 | svg { 57 | width: 100%; 58 | height: 25rem; 59 | } 60 | 61 | p { 62 | font-size: 2.5rem; 63 | text-align: center; 64 | } 65 | `; 66 | -------------------------------------------------------------------------------- /components/NavigationDrawer.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link' 2 | import { useRouter } from 'next/router' 3 | import { PropsWithChildren, useEffect, useRef } from 'react' 4 | import styled from 'styled-components' 5 | import { NavItems } from 'types' 6 | import ClientOnly from './ClientOnly' 7 | import CloseIcon from './CloseIcon' 8 | import OriginalDrawer from './Drawer' 9 | 10 | type NavigationDrawerProps = PropsWithChildren<{ items: NavItems }> 11 | 12 | export default function NavigationDrawer({ children, items }: NavigationDrawerProps) { 13 | return ( 14 | 15 | 16 | 17 | 18 |
19 |
20 | 21 | 22 |
23 |
24 |
25 |
26 |
27 | {children} 28 |
29 | ) 30 | } 31 | 32 | function NavItemsList({ items }: NavigationDrawerProps) { 33 | const { close } = OriginalDrawer.useDrawer() 34 | const router = useRouter() 35 | 36 | useEffect(() => { 37 | function handleRouteChangeComplete() { 38 | close() 39 | } 40 | 41 | router.events.on('routeChangeComplete', handleRouteChangeComplete) 42 | return () => router.events.off('routeChangeComplete', handleRouteChangeComplete) 43 | }, [close, router]) 44 | 45 | return ( 46 |
    47 | {items.map((singleItem, idx) => { 48 | return ( 49 | 50 | {singleItem.title} 51 | 52 | ) 53 | })} 54 |
55 | ) 56 | } 57 | 58 | function DrawerCloseButton() { 59 | const ref = useRef(null) 60 | const a11yProps = OriginalDrawer.useA11yCloseButton(ref) 61 | 62 | return 63 | } 64 | 65 | const Wrapper = styled.div` 66 | .my-drawer { 67 | width: 100%; 68 | height: 100%; 69 | z-index: var(--z-drawer); 70 | background: rgb(var(--background)); 71 | transition: margin-left 0.3s cubic-bezier(0.82, 0.085, 0.395, 0.895); 72 | overflow: hidden; 73 | } 74 | 75 | .my-drawer-container { 76 | position: relative; 77 | height: 100%; 78 | margin: auto; 79 | max-width: 70rem; 80 | padding: 0 1.2rem; 81 | } 82 | 83 | .close-icon { 84 | position: absolute; 85 | right: 2rem; 86 | top: 2rem; 87 | } 88 | 89 | .drawer-closed { 90 | margin-left: -100%; 91 | } 92 | 93 | .drawer-opened { 94 | margin-left: 0; 95 | } 96 | 97 | ul { 98 | height: 100%; 99 | display: flex; 100 | flex-direction: column; 101 | justify-content: center; 102 | align-items: center; 103 | padding: 0; 104 | margin: 0; 105 | list-style: none; 106 | 107 | & > *:not(:last-child) { 108 | margin-bottom: 3rem; 109 | } 110 | } 111 | ` 112 | 113 | const NavItem = styled.li` 114 | a { 115 | font-size: 3rem; 116 | text-transform: uppercase; 117 | display: block; 118 | color: currentColor; 119 | text-decoration: none; 120 | border-radius: 0.5rem; 121 | padding: 0.5rem 1rem; 122 | text-align: center; 123 | } 124 | ` 125 | -------------------------------------------------------------------------------- /components/NewsletterModal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import MailchimpSubscribe, { DefaultFormFields } from 'react-mailchimp-subscribe'; 3 | import styled from 'styled-components'; 4 | import { EnvVars } from 'env'; 5 | import useEscClose from 'hooks/useEscKey'; 6 | import { media } from 'utils/media'; 7 | import Button from './Button'; 8 | import CloseIcon from './CloseIcon'; 9 | import Container from './Container'; 10 | import Input from './Input'; 11 | import MailSentState from './MailSentState'; 12 | import Overlay from './Overlay'; 13 | 14 | export interface NewsletterModalProps { 15 | onClose: () => void; 16 | } 17 | 18 | export default function NewsletterModal({ onClose }: NewsletterModalProps) { 19 | const [email, setEmail] = useState(''); 20 | 21 | useEscClose({ onClose }); 22 | 23 | function onSubmit(event: React.FormEvent, enrollNewsletter: (props: DefaultFormFields) => void) { 24 | event.preventDefault(); 25 | console.log({ email }); 26 | if (email) { 27 | enrollNewsletter({ EMAIL: email }); 28 | } 29 | } 30 | 31 | return ( 32 | { 35 | const hasSignedUp = status === 'success'; 36 | return ( 37 | 38 | 39 | ) => onSubmit(event, subscribe)}> 40 | 41 | 42 | 43 | {hasSignedUp && } 44 | {!hasSignedUp && ( 45 | <> 46 | Are you ready to enroll to the best newsletter ever? 47 | 48 | ) => setEmail(e.target.value)} 51 | placeholder="Enter your email..." 52 | required 53 | /> 54 | 55 | Submit 56 | 57 | 58 | {message && } 59 | 60 | )} 61 | 62 | 63 | 64 | ); 65 | }} 66 | /> 67 | ); 68 | } 69 | 70 | const Card = styled.form` 71 | display: flex; 72 | position: relative; 73 | flex-direction: column; 74 | margin: auto; 75 | padding: 10rem 5rem; 76 | background: rgb(var(--modalBackground)); 77 | border-radius: 0.6rem; 78 | max-width: 70rem; 79 | overflow: hidden; 80 | color: rgb(var(--text)); 81 | 82 | ${media('<=tablet')} { 83 | padding: 7.5rem 2.5rem; 84 | } 85 | `; 86 | 87 | const CloseIconContainer = styled.div` 88 | position: absolute; 89 | right: 2rem; 90 | top: 2rem; 91 | 92 | svg { 93 | cursor: pointer; 94 | width: 2rem; 95 | } 96 | `; 97 | 98 | const Title = styled.div` 99 | font-size: 3.2rem; 100 | font-weight: bold; 101 | line-height: 1.1; 102 | letter-spacing: -0.03em; 103 | text-align: center; 104 | color: rgb(var(--text)); 105 | 106 | ${media('<=tablet')} { 107 | font-size: 2.6rem; 108 | } 109 | `; 110 | 111 | const ErrorMessage = styled.p` 112 | color: rgb(var(--errorColor)); 113 | font-size: 1.5rem; 114 | margin: 1rem 0; 115 | text-align: center; 116 | `; 117 | 118 | const Row = styled.div` 119 | display: flex; 120 | justify-content: center; 121 | align-items: center; 122 | height: 100%; 123 | width: 100%; 124 | margin-top: 3rem; 125 | 126 | ${media('<=tablet')} { 127 | flex-direction: column; 128 | } 129 | `; 130 | 131 | const CustomButton = styled(Button)` 132 | height: 100%; 133 | padding: 1.8rem; 134 | margin-left: 1.5rem; 135 | box-shadow: var(--shadow-lg); 136 | 137 | ${media('<=tablet')} { 138 | width: 100%; 139 | margin-left: 0; 140 | margin-top: 1rem; 141 | } 142 | `; 143 | 144 | const CustomInput = styled(Input)` 145 | width: 60%; 146 | 147 | ${media('<=tablet')} { 148 | width: 100%; 149 | } 150 | `; 151 | -------------------------------------------------------------------------------- /components/OverTitle.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { media } from 'utils/media'; 3 | 4 | const OverTitle = styled.span` 5 | display: block; 6 | &::before { 7 | position: relative; 8 | bottom: -0.1em; 9 | content: ''; 10 | display: inline-block; 11 | width: 0.9em; 12 | height: 0.9em; 13 | background-color: rgb(var(--primary)); 14 | line-height: 0; 15 | margin-right: 1em; 16 | } 17 | 18 | font-size: 1.3rem; 19 | letter-spacing: 0.02em; 20 | font-weight: bold; 21 | line-height: 0; 22 | text-transform: uppercase; 23 | 24 | ${media('<=desktop')} { 25 | line-height: 1.5; 26 | } 27 | `; 28 | 29 | export default OverTitle; 30 | -------------------------------------------------------------------------------- /components/Overlay.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Overlay = styled.div` 4 | position: fixed; 5 | inset: 0; 6 | background: rgba(var(--secondary), 0.997); 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: center; 10 | align-items: center; 11 | z-index: var(--z-modal); 12 | color: rgb(var(--textSecondary)); 13 | `; 14 | 15 | export default Overlay; 16 | -------------------------------------------------------------------------------- /components/Page.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { PropsWithChildren } from 'react'; 3 | import styled from 'styled-components'; 4 | import { EnvVars } from 'env'; 5 | import { media } from 'utils/media'; 6 | import Container from './Container'; 7 | import SectionTitle from './SectionTitle'; 8 | 9 | export interface PageProps { 10 | title: string; 11 | description?: string; 12 | } 13 | 14 | export default function Page({ title, description, children }: PropsWithChildren) { 15 | return ( 16 | <> 17 | 18 | 19 | {title} | {EnvVars.SITE_NAME} 20 | 21 | 22 | 23 | 24 | 25 | 26 | {title} 27 | {description && {description}} 28 | 29 | 30 | 31 | {children} 32 | 33 | 34 | 35 | ); 36 | } 37 | 38 | const Wrapper = styled.div` 39 | background: rgb(var(--background)); 40 | `; 41 | 42 | const HeaderContainer = styled.div` 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | background: rgb(var(--secondary)); 47 | min-height: 40rem; 48 | `; 49 | 50 | const Title = styled(SectionTitle)` 51 | color: rgb(var(--textSecondary)); 52 | margin-bottom: 2rem; 53 | `; 54 | 55 | const Description = styled.div` 56 | font-size: 1.8rem; 57 | color: rgba(var(--textSecondary), 0.8); 58 | text-align: center; 59 | max-width: 60%; 60 | margin: auto; 61 | 62 | ${media('<=tablet')} { 63 | max-width: 100%; 64 | } 65 | `; 66 | 67 | const ChildrenWrapper = styled.div` 68 | margin-top: 10rem; 69 | margin-bottom: 10rem; 70 | `; 71 | -------------------------------------------------------------------------------- /components/PricingCard.tsx: -------------------------------------------------------------------------------- 1 | import { PropsWithChildren } from 'react'; 2 | import styled from 'styled-components'; 3 | import { media } from 'utils/media'; 4 | import Button from './Button'; 5 | import RichText from './RichText'; 6 | 7 | interface PricingCardProps { 8 | title: string; 9 | description: string; 10 | benefits: string[]; 11 | isOutlined?: boolean; 12 | } 13 | 14 | export default function PricingCard({ title, description, benefits, isOutlined, children }: PropsWithChildren) { 15 | const isAnyBenefitPresent = benefits?.length; 16 | 17 | return ( 18 | 19 | {title} 20 | {description} 21 | 22 | {children} 23 | {isAnyBenefitPresent && ( 24 | 25 |
    26 | {benefits.map((singleBenefit, idx) => ( 27 |
  • {singleBenefit}
  • 28 | ))} 29 |
30 |
31 | )} 32 |
33 | Get started 34 |
35 | ); 36 | } 37 | 38 | const Wrapper = styled.div<{ isOutlined?: boolean }>` 39 | display: flex; 40 | flex-direction: column; 41 | padding: 3rem; 42 | background: rgb(var(--cardBackground)); 43 | box-shadow: ${(p) => (p.isOutlined ? 'var(--shadow-lg)' : 'var(--shadow-md)')}; 44 | transform: ${(p) => (p.isOutlined ? 'scale(1.1)' : 'scale(1.0)')}; 45 | text-align: center; 46 | 47 | & > *:not(:first-child) { 48 | margin-top: 1rem; 49 | } 50 | 51 | ${media('<=desktop')} { 52 | box-shadow: var(--shadow-md); 53 | transform: none; 54 | order: ${(p) => (p.isOutlined ? -1 : 0)}; 55 | } 56 | `; 57 | 58 | const Title = styled.h3` 59 | font-size: 4rem; 60 | text-transform: capitalize; 61 | `; 62 | 63 | const Description = styled.p` 64 | font-size: 2.5rem; 65 | `; 66 | 67 | const PriceContainer = styled.div` 68 | margin: auto; 69 | 70 | & > *:not(:first-child) { 71 | margin-top: 2rem; 72 | } 73 | `; 74 | 75 | const Price = styled.div` 76 | display: flex; 77 | justify-content: center; 78 | align-items: flex-end; 79 | font-size: 4rem; 80 | line-height: 1; 81 | font-weight: bold; 82 | 83 | span { 84 | font-size: 2rem; 85 | font-weight: normal; 86 | } 87 | `; 88 | 89 | const CustomRichText = styled(RichText)` 90 | li { 91 | margin: auto; 92 | width: fit-content; 93 | } 94 | `; 95 | 96 | const CustomButton = styled(Button)` 97 | width: 100%; 98 | `; 99 | -------------------------------------------------------------------------------- /components/Quote.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | interface QuoteProps { 5 | content: string; 6 | author: string; 7 | cite: string; 8 | } 9 | 10 | export default function Quote({ content, author, cite }: QuoteProps) { 11 | return ( 12 | 13 |
{content}
14 | — {author} 15 |
16 | ); 17 | } 18 | 19 | const Container = styled.figure` 20 | border-left: 1px solid rgb(var(--secondary)); 21 | padding: 3rem; 22 | quotes: ${`"\\201c" "\\201d" "\\2018" "\\2019"`}; 23 | color: rgb(var(--secondary)); 24 | margin-bottom: 3.7rem; 25 | 26 | &::before { 27 | content: open-quote; 28 | font-size: 8em; 29 | line-height: 0.1em; 30 | margin-right: 0.25em; 31 | vertical-align: -0.4em; 32 | opacity: 0.6; 33 | font-family: arial, sans-serif; 34 | } 35 | `; 36 | 37 | const Blockquote = styled.blockquote` 38 | color: rgb(var(--text)); 39 | display: inline; 40 | font-size: 2.2rem; 41 | line-height: 3rem; 42 | font-style: italic; 43 | hanging-punctuation: first; 44 | `; 45 | 46 | const Caption = styled.figcaption` 47 | color: rgb(var(--text)); 48 | display: block; 49 | font-size: 1.6rem; 50 | margin-top: 2.5rem; 51 | `; 52 | -------------------------------------------------------------------------------- /components/RichText.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { media } from 'utils/media'; 3 | 4 | const RichText = styled.div` 5 | font-size: 1.8rem; 6 | opacity: 0.8; 7 | line-height: 1.6; 8 | 9 | ol, 10 | ul { 11 | list-style: none; 12 | padding: 0rem; 13 | 14 | li { 15 | padding-left: 2rem; 16 | position: relative; 17 | 18 | & > * { 19 | display: inline-block; 20 | vertical-align: top; 21 | } 22 | 23 | &::before { 24 | position: absolute; 25 | content: 'L'; 26 | left: 0; 27 | top: 0; 28 | text-align: center; 29 | color: rgb(var(--primary)); 30 | font-family: arial; 31 | transform: scaleX(-1) rotate(-35deg); 32 | } 33 | } 34 | } 35 | 36 | table { 37 | border-collapse: collapse; 38 | 39 | table-layout: fixed; 40 | border-spacing: 0; 41 | border-radius: 5px; 42 | border-collapse: separate; 43 | } 44 | th { 45 | background: rgb(var(--textSecondary)); 46 | } 47 | 48 | th, 49 | td { 50 | border: 1px solid rgb(var(--textSecondary)); 51 | padding: 1rem; 52 | } 53 | 54 | tr:nth-child(even) { 55 | background: rgb(var(--textSecondary)); 56 | } 57 | 58 | ${media('<=desktop')} { 59 | font-size: 1.5rem; 60 | } 61 | `; 62 | 63 | export default RichText; 64 | -------------------------------------------------------------------------------- /components/SectionTitle.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { media } from 'utils/media'; 3 | 4 | const SectionTitle = styled.div` 5 | font-size: 5.2rem; 6 | font-weight: bold; 7 | line-height: 1.1; 8 | letter-spacing: -0.03em; 9 | text-align: center; 10 | 11 | ${media('<=tablet')} { 12 | font-size: 4.6rem; 13 | } 14 | `; 15 | 16 | export default SectionTitle; 17 | -------------------------------------------------------------------------------- /components/Separator.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { media } from 'utils/media'; 3 | 4 | const Separator = styled.div` 5 | margin: 12.5rem 0; 6 | border: 1px solid rgba(var(--secondary), 0.025); 7 | height: 0px; 8 | 9 | ${media('<=tablet')} { 10 | margin: 7.5rem 0; 11 | } 12 | `; 13 | 14 | export default Separator; 15 | -------------------------------------------------------------------------------- /components/Spacer.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const Spacer = styled.hr` 4 | width: 100%; 5 | border-color: currentColor; 6 | `; 7 | 8 | export default Spacer; 9 | -------------------------------------------------------------------------------- /components/ThreeLayersCircle.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { media } from 'utils/media'; 3 | 4 | export interface ThreeLayersCircleProps { 5 | baseColor: string; 6 | secondColor: string; 7 | } 8 | 9 | const ThreeLayersCircle = styled.div` 10 | position: relative; 11 | display: inline-block; 12 | opacity: 0.8; 13 | width: 5rem; 14 | height: 5rem; 15 | border-radius: 100rem; 16 | background: rgb(${(p) => p.baseColor}); 17 | z-index: 0; 18 | transition: background 0.2s; 19 | 20 | ${media('<=tablet')} { 21 | width: 4rem; 22 | height: 4rem; 23 | } 24 | 25 | &:after, 26 | &:before { 27 | content: ''; 28 | position: absolute; 29 | width: 3.5rem; 30 | height: 3.5rem; 31 | top: 50%; 32 | left: 50%; 33 | transform: translate(-50%, -50%); 34 | border-radius: 100rem; 35 | z-index: -1; 36 | } 37 | 38 | &:after { 39 | width: 4rem; 40 | height: 4rem; 41 | background: rgb(${(p) => p.secondColor}); 42 | z-index: -2; 43 | } 44 | 45 | &:before { 46 | width: 2rem; 47 | height: 2rem; 48 | background: rgb(${(p) => p.baseColor}); 49 | } 50 | `; 51 | 52 | export default ThreeLayersCircle; 53 | -------------------------------------------------------------------------------- /components/WaveCta.tsx: -------------------------------------------------------------------------------- 1 | import NextLink from 'next/link'; 2 | import styled from 'styled-components'; 3 | import Button from 'components/Button'; 4 | import ButtonGroup from 'components/ButtonGroup'; 5 | import Container from 'components/Container'; 6 | import SectionTitle from 'components/SectionTitle'; 7 | import { useNewsletterModalContext } from 'contexts/newsletter-modal.context'; 8 | import { media } from 'utils/media'; 9 | 10 | export default function WaveCta() { 11 | const { setIsModalOpened } = useNewsletterModalContext(); 12 | 13 | return ( 14 | <> 15 | 16 | 21 | 22 | 23 | 24 | Lorem ipsum dolor, sit amet consectetur adipisicing elit. Temporibus delectus? 25 | 26 | 29 | 30 | 31 | Features 32 | 33 | 34 | 35 | 36 | 37 | 38 | ); 39 | } 40 | 41 | const CtaWrapper = styled.div` 42 | background: rgb(var(--secondary)); 43 | margin-top: -1rem; 44 | padding-bottom: 16rem; 45 | 46 | ${media('<=tablet')} { 47 | padding-top: 8rem; 48 | } 49 | `; 50 | 51 | const Title = styled(SectionTitle)` 52 | color: rgb(var(--textSecondary)); 53 | margin-bottom: 4rem; 54 | `; 55 | 56 | const OutlinedButton = styled(Button)` 57 | border: 1px solid rgb(var(--textSecondary)); 58 | color: rgb(var(--textSecondary)); 59 | `; 60 | 61 | const CustomButtonGroup = styled(ButtonGroup)` 62 | justify-content: center; 63 | `; 64 | -------------------------------------------------------------------------------- /components/YoutubeVideo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | 4 | import playIcon from '../public/play-icon.svg'; 5 | 6 | interface YoutubeVideoProps { 7 | title?: string; 8 | url: string; 9 | } 10 | 11 | export default function YoutubeVideo(props: YoutubeVideoProps) { 12 | const { title, url } = props; 13 | const videoHash = extractVideoHashFromUrl(url); 14 | const srcDoc = ` 48 | 49 | ${title || 50 | Play the video 51 | `; 52 | return ( 53 | 54 | 66 | 67 | ); 68 | } 69 | 70 | function extractVideoHashFromUrl(url: string) { 71 | const videoHashQueryParamKey = 'v'; 72 | const searchParams = new URL(url).search; 73 | return new URLSearchParams(searchParams).getAll(videoHashQueryParamKey); 74 | } 75 | 76 | export const VideoContainer = styled.div` 77 | display: flex; 78 | position: relative; 79 | border-radius: 20px; 80 | overflow: hidden; 81 | -webkit-mask-image: -webkit-radial-gradient(white, black); 82 | 83 | &:before { 84 | display: block; 85 | content: ''; 86 | width: 100%; 87 | padding-top: 56.25%; 88 | } 89 | `; 90 | 91 | const VideoFrame = styled.iframe` 92 | position: absolute; 93 | top: 0; 94 | right: 0; 95 | bottom: 0; 96 | left: 0; 97 | width: 100%; 98 | height: 100%; 99 | `; 100 | -------------------------------------------------------------------------------- /contexts/newsletter-modal.context.tsx: -------------------------------------------------------------------------------- 1 | import React, { Dispatch, PropsWithChildren, SetStateAction, useContext, useState } from 'react'; 2 | 3 | interface NewsletterModalContextProps { 4 | isModalOpened: boolean; 5 | setIsModalOpened: Dispatch>; 6 | } 7 | 8 | export const NewsletterModalContext = React.createContext(null); 9 | 10 | export function NewsletterModalContextProvider({ children }: PropsWithChildren) { 11 | const [isModalOpened, setIsModalOpened] = useState(false); 12 | 13 | return ( 14 | 20 | {children} 21 | 22 | ); 23 | } 24 | 25 | export function useNewsletterModalContext() { 26 | const context = useContext(NewsletterModalContext); 27 | if (!context) { 28 | throw new Error('useNewsletterModalContext can only be used inside NewsletterModalContextProvider'); 29 | } 30 | return context; 31 | } 32 | -------------------------------------------------------------------------------- /env.ts: -------------------------------------------------------------------------------- 1 | export const EnvVars = { 2 | SITE_NAME: 'My SaaS Startup', 3 | OG_IMAGES_URL: 'https://next-saas-starter-ashy.vercel.app/og-images/', 4 | URL: 'https://next-saas-starter-ashy.vercel.app/', 5 | MAILCHIMP_SUBSCRIBE_URL: 'https://bstefanski.us5.list-manage.com/subscribe/post?u=66b4c22d5c726ae22da1dcb2e&id=679fb0eec9', 6 | }; 7 | -------------------------------------------------------------------------------- /hooks/useClipboard.ts: -------------------------------------------------------------------------------- 1 | import { useClipboard } from 'use-clipboard-copy'; 2 | 3 | export { useClipboard }; 4 | -------------------------------------------------------------------------------- /hooks/useEscKey.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect } from 'react'; 2 | 3 | export interface UseEscCloseProps { 4 | onClose: () => void; 5 | } 6 | 7 | export default function useEscClose({ onClose }: UseEscCloseProps) { 8 | const handleUserKeyPress = useCallback( 9 | (event) => { 10 | const { keyCode } = event; 11 | const escapeKeyCode = 27; 12 | if (keyCode === escapeKeyCode) { 13 | onClose(); 14 | } 15 | }, 16 | [onClose], 17 | ); 18 | 19 | useEffect(() => { 20 | window.addEventListener('keydown', handleUserKeyPress); 21 | return () => { 22 | window.removeEventListener('keydown', handleUserKeyPress); 23 | }; 24 | }, [handleUserKeyPress]); 25 | } 26 | -------------------------------------------------------------------------------- /hooks/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import useResizeObserver from 'use-resize-observer'; 2 | 3 | export { useResizeObserver }; 4 | -------------------------------------------------------------------------------- /hooks/useScrollPosition.ts: -------------------------------------------------------------------------------- 1 | import { useScrollPosition as originalUseScrollPosition } from '@n8tb1t/use-scroll-position'; 2 | 3 | declare type ElementRef = React.MutableRefObject; 4 | 5 | type Axis = { x: number; y: number }; 6 | export type ScrollPositionEffectProps = { prevPos: Axis; currPos: Axis }; 7 | 8 | export function useScrollPosition( 9 | effect: (props: ScrollPositionEffectProps) => void, 10 | deps?: React.DependencyList | undefined, 11 | element?: ElementRef | undefined, 12 | useWindow?: boolean | undefined, 13 | wait?: number | undefined, 14 | boundingElement?: ElementRef | undefined, 15 | ) { 16 | return originalUseScrollPosition(effect, deps, element, useWindow, wait, boundingElement); 17 | } 18 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | const CopyPlugin = require('copy-webpack-plugin'); 2 | 3 | const withBundleAnalyzer = require('@next/bundle-analyzer')({ 4 | enabled: process.env.ANALYZE === 'true', 5 | }); 6 | 7 | module.exports = withBundleAnalyzer({ 8 | reactStrictMode: true, 9 | pageExtensions: ['js', 'jsx', 'mdx', 'ts', 'tsx'], 10 | images: { 11 | domains: ['github.blog'], 12 | deviceSizes: [320, 640, 1080, 1200], 13 | imageSizes: [64, 128], 14 | }, 15 | swcMinify: true, 16 | compiler: { 17 | styledComponents: true, 18 | }, 19 | webpack: (config, { buildId, dev, isServer, defaultLoaders, webpack }) => { 20 | config.module.rules.push({ 21 | test: /\.svg$/, 22 | issuer: { 23 | and: [/\.(js|ts)x?$/], 24 | }, 25 | use: [{ loader: '@svgr/webpack' }, { loader: 'url-loader' }], 26 | }); 27 | 28 | return config; 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-saas-starter", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "yarn tinacms server:start -c \"next dev\"", 7 | "build": "yarn tinacms server:start -c \"next build\"", 8 | "start": "yarn tinacms server:start -c \"next start\"", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@accessible/drawer": "^3.0.2", 13 | "@n8tb1t/use-scroll-position": "^2.0.3", 14 | "@sendgrid/mail": "^7.7.0", 15 | "@svgr/webpack": "^8.0.0", 16 | "css-in-js-media": "^2.0.1", 17 | "date-fns": "^2.29.3", 18 | "gray-matter": "^4.0.3", 19 | "lodash": "^4.17.21", 20 | "next": "12.1.0", 21 | "nextjs-color-mode": "^1.0.5", 22 | "polished": "^4.1.3", 23 | "prism-react-renderer": "^1.3.5", 24 | "react": "17.0.2", 25 | "react-animate-height": "^2.0.23", 26 | "react-dom": "17.0.2", 27 | "react-hook-form": "^7.17.4", 28 | "react-mailchimp-subscribe": "^2.1.3", 29 | "react-schemaorg": "^2.0.0", 30 | "react-share": "^4.4.1", 31 | "reading-time": "^1.5.0", 32 | "schema-dts": "^1.1.2", 33 | "styled-components": "^5.3.5", 34 | "swiper": "9.3.2", 35 | "tinacms": "^1.5.8", 36 | "url-loader": "^4.1.1", 37 | "use-clipboard-copy": "^0.2.0", 38 | "use-resize-observer": "^9.1.0" 39 | }, 40 | "devDependencies": { 41 | "@babel/eslint-parser": "^7.21.3", 42 | "@fec/remark-a11y-emoji": "^3.1.0", 43 | "@next/bundle-analyzer": "^13.3.1", 44 | "@tinacms/cli": "^0.60.0", 45 | "@types/react": "^17.0.20", 46 | "@types/react-mailchimp-subscribe": "^2.1.1", 47 | "@types/styled-components": "^5.1.25", 48 | "@types/swiper": "^6.0.0", 49 | "@typescript-eslint/eslint-plugin": "^5.0.0", 50 | "@typescript-eslint/parser": "^5.59.9", 51 | "all-contributors-cli": "^6.26.0", 52 | "@babel/eslint-parser": "^7.11.0", 53 | "copy-webpack-plugin": "^11.0.0", 54 | "eslint": "^8.0.0", 55 | "eslint-config-next": "^13.2.4", 56 | "eslint-config-prettier": "^8.3.0", 57 | "eslint-config-react-app": "^7.0.0", 58 | "eslint-plugin-flowtype": "^8.0.0", 59 | "eslint-plugin-import": "^2.24.2", 60 | "eslint-plugin-jsx-a11y": "^6.7.1", 61 | "eslint-plugin-react": "^7.25.1", 62 | "eslint-plugin-react-hooks": "^4.6.0", 63 | "next-mdx-remote": "^4.4.1", 64 | "remark-breaks": "^3.0.1", 65 | "remark-external-links": "^9.0.1", 66 | "remark-footnotes": "^4.0.1", 67 | "remark-gfm": "^3.0.1", 68 | "remark-sectionize": "^2.0.0", 69 | "remark-slug": "^7.0.1", 70 | "typescript": "5.1.6" 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /pages/404.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Container from 'components/Container'; 3 | import NotFoundIllustration from 'components/NotFoundIllustration'; 4 | 5 | export default function NotFoundPage() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | 404 13 | Oh, that's unfortunate! Page not found 😔 14 | 15 | 16 | ); 17 | } 18 | 19 | const Wrapper = styled.div` 20 | background: rgb(var(--background)); 21 | margin: 10rem 0; 22 | text-align: center; 23 | `; 24 | 25 | const Title = styled.h1` 26 | font-size: 5rem; 27 | margin-top: 5rem; 28 | `; 29 | 30 | const Description = styled.div` 31 | font-size: 3rem; 32 | opacity: 0.8; 33 | margin-top: 2.5rem; 34 | `; 35 | 36 | const ImageContainer = styled.div` 37 | width: 25rem; 38 | margin: auto; 39 | `; 40 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import 'swiper/css'; 2 | import 'swiper/css/bundle'; 3 | import 'swiper/css/navigation'; 4 | import 'swiper/css/autoplay'; 5 | 6 | import { AppProps } from 'next/dist/shared/lib/router/router'; 7 | import dynamic from 'next/dynamic'; 8 | import Head from 'next/head'; 9 | import { ColorModeScript } from 'nextjs-color-mode'; 10 | import React, { PropsWithChildren } from 'react'; 11 | import { TinaEditProvider } from 'tinacms/dist/edit-state'; 12 | 13 | import Footer from 'components/Footer'; 14 | import { GlobalStyle } from 'components/GlobalStyles'; 15 | import Navbar from 'components/Navbar'; 16 | import NavigationDrawer from 'components/NavigationDrawer'; 17 | import NewsletterModal from 'components/NewsletterModal'; 18 | import WaveCta from 'components/WaveCta'; 19 | import { NewsletterModalContextProvider, useNewsletterModalContext } from 'contexts/newsletter-modal.context'; 20 | import { NavItems } from 'types'; 21 | 22 | const navItems: NavItems = [ 23 | { title: 'Awesome SaaS Features', href: '/features' }, 24 | { title: 'Pricing', href: '/pricing' }, 25 | { title: 'Contact', href: '/contact' }, 26 | { title: 'Sign up', href: '/sign-up', outlined: true }, 27 | ]; 28 | 29 | const TinaCMS = dynamic(() => import('tinacms'), { ssr: false }); 30 | 31 | function MyApp({ Component, pageProps }: AppProps) { 32 | return ( 33 | <> 34 | 35 | 36 | 37 | 38 | {/* */} 39 | {/* */} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 65 | {(livePageProps: any) => } 66 | 67 | } 68 | > 69 | 70 | 71 | 72 |