├── .tina ├── __generated__ │ ├── .gitignore │ ├── frags.gql │ ├── queries.gql │ ├── _lookup.json │ └── schema.gql └── schema.ts ├── .vscode └── settings.json ├── pages ├── admin │ └── [[...tina]].tsx ├── pricing.tsx ├── contact.tsx ├── api │ └── sendEmail.ts ├── 404.tsx ├── _document.tsx ├── blog │ ├── index.tsx │ └── [slug].tsx ├── _app.tsx ├── index.tsx └── features.tsx ├── public ├── favicon.ico ├── demo-illustration-3.png ├── demo-illustration-4.png ├── demo-illustration-5.png ├── testimonials │ ├── author-photo-1.jpeg │ ├── author-photo-2.jpeg │ ├── author-photo-3.jpeg │ └── company-logo-2.svg ├── posts │ └── test-article │ │ ├── example-image-2.png │ │ └── example-image-1.jpeg ├── play-icon.svg ├── vercel.svg ├── partners │ ├── logoipsum-logo-7.svg │ ├── logoipsum-logo-6.svg │ ├── logoipsum-logo-5.svg │ └── logoipsum-logo-1.svg ├── prism-theme.css └── demo-illustration-2.svg ├── utils ├── media.ts ├── formatDate.ts ├── readTime.ts └── postsFetcher.ts ├── hooks ├── useClipboard.ts ├── useResizeObserver.ts ├── useEscKey.ts └── useScrollPosition.ts ├── components ├── Drawer.tsx ├── Spacer.tsx ├── Container.tsx ├── AutofitGrid.tsx ├── Separator.tsx ├── SectionTitle.tsx ├── Overlay.tsx ├── ClientOnly.tsx ├── Icon.tsx ├── Input.tsx ├── ButtonGroup.tsx ├── CloseIcon.tsx ├── OverTitle.tsx ├── HamburgerIcon.tsx ├── Button.tsx ├── Link.tsx ├── BasicCard.tsx ├── ThreeLayersCircle.tsx ├── Quote.tsx ├── RichText.tsx ├── Collapse.tsx ├── ArticleImage.tsx ├── ColorSwitcher.tsx ├── Page.tsx ├── WaveCta.tsx ├── Accordion.tsx ├── BasicSection.tsx ├── YoutubeVideo.tsx ├── PricingCard.tsx ├── ArticleCard.tsx ├── MDXRichText.tsx ├── NavigationDrawer.tsx ├── MailSentState.tsx ├── GlobalStyles.tsx ├── NewsletterModal.tsx ├── Footer.tsx └── Code.tsx ├── renovate.json ├── .prettierrc ├── .env.example ├── next-env.d.ts ├── env.ts ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ └── codeql-analysis.yml ├── types.ts ├── tsconfig.json ├── views ├── SingleArticlePage │ ├── MetadataHead.tsx │ ├── Header.tsx │ ├── ShareWidget.tsx │ ├── OpenGraphHead.tsx │ └── StructuredDataHead.tsx ├── ContactPage │ ├── InformationSection.tsx │ └── FormSection.tsx ├── PricingPage │ ├── PricingTablesSection.tsx │ └── FaqSection.tsx └── HomePage │ ├── Partners.tsx │ ├── Cta.tsx │ ├── ScrollableBlogPosts.tsx │ ├── Hero.tsx │ ├── Features.tsx │ └── Testimonials.tsx ├── next.config.js ├── contexts └── newsletter-modal.context.tsx ├── LICENSE ├── .eslintrc.json ├── .all-contributorsrc ├── package.json └── posts ├── test-article-2.mdx ├── test-article-4.mdx ├── test-article-5.mdx ├── test-article-6.mdx ├── test-article.mdx └── test-article-3.mdx /.tina/__generated__/.gitignore: -------------------------------------------------------------------------------- 1 | db -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib", 3 | } 4 | -------------------------------------------------------------------------------- /pages/admin/[[...tina]].tsx: -------------------------------------------------------------------------------- 1 | import { TinaAdmin } from 'tinacms'; 2 | 3 | export default TinaAdmin; 4 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /utils/media.ts: -------------------------------------------------------------------------------- 1 | import originalMedia from 'css-in-js-media'; 2 | 3 | export const media = originalMedia; 4 | -------------------------------------------------------------------------------- /hooks/useClipboard.ts: -------------------------------------------------------------------------------- 1 | import { useClipboard } from 'use-clipboard-copy'; 2 | 3 | export { useClipboard }; 4 | -------------------------------------------------------------------------------- /components/Drawer.tsx: -------------------------------------------------------------------------------- 1 | import * as OriginalDrawer from '@accessible/drawer' 2 | 3 | export default OriginalDrawer 4 | -------------------------------------------------------------------------------- /hooks/useResizeObserver.ts: -------------------------------------------------------------------------------- 1 | import useResizeObserver from 'use-resize-observer'; 2 | 3 | export { useResizeObserver }; 4 | -------------------------------------------------------------------------------- /public/demo-illustration-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/demo-illustration-3.png -------------------------------------------------------------------------------- /public/demo-illustration-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/demo-illustration-4.png -------------------------------------------------------------------------------- /public/demo-illustration-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/demo-illustration-5.png -------------------------------------------------------------------------------- /.tina/__generated__/frags.gql: -------------------------------------------------------------------------------- 1 | fragment PostsParts on Posts { 2 | title 3 | description 4 | date 5 | tags 6 | imageUrl 7 | body 8 | } 9 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 140, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "singleQuote": true, 6 | "trailingComma": "all" 7 | } 8 | -------------------------------------------------------------------------------- /public/testimonials/author-photo-1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/testimonials/author-photo-1.jpeg -------------------------------------------------------------------------------- /public/testimonials/author-photo-2.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/testimonials/author-photo-2.jpeg -------------------------------------------------------------------------------- /public/testimonials/author-photo-3.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/testimonials/author-photo-3.jpeg -------------------------------------------------------------------------------- /public/posts/test-article/example-image-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/posts/test-article/example-image-2.png -------------------------------------------------------------------------------- /public/posts/test-article/example-image-1.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Daniel-Kennison/next.js-saas-starter/HEAD/public/posts/test-article/example-image-1.jpeg -------------------------------------------------------------------------------- /.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="" -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /utils/formatDate.ts: -------------------------------------------------------------------------------- 1 | import format from 'date-fns/format'; 2 | import isValid from 'date-fns/isValid'; 3 | 4 | export function formatDate(date: number | Date) { 5 | return isValid(date) ? format(date, 'do MMMM yyyy') : 'N/A'; 6 | } 7 | -------------------------------------------------------------------------------- /utils/readTime.ts: -------------------------------------------------------------------------------- 1 | import * as readingTime from 'reading-time'; 2 | 3 | export function getReadTime(text: string) { 4 | const readTime = Math.round(readingTime.default(text).minutes); 5 | return `${readTime < 1 ? '< 1' : Math.round(readTime)}min read`; 6 | } 7 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /public/play-icon.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /types.ts: -------------------------------------------------------------------------------- 1 | export type SingleNavItem = { title: string; href: string; outlined?: boolean }; 2 | 3 | export type NavItems = SingleNavItem[]; 4 | 5 | export type SingleArticle = { 6 | slug: string; 7 | content: string; 8 | meta: { 9 | title: string; 10 | description: string; 11 | date: string; 12 | tags: string; 13 | imageUrl: string; 14 | }; 15 | }; 16 | 17 | export type NonNullableChildren = { [P in keyof T]: Required> }; 18 | 19 | export type NonNullableChildrenDeep = { 20 | [P in keyof T]-?: NonNullableChildrenDeep>; 21 | }; 22 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "incremental": true 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /views/SingleArticlePage/MetadataHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | import { EnvVars } from 'env'; 4 | 5 | interface MetadataHeadProps { 6 | title: string; 7 | description: string; 8 | author: string; 9 | } 10 | 11 | export default function MetadataHead(props: MetadataHeadProps) { 12 | const { title, description, author } = props; 13 | 14 | return ( 15 | 16 | 17 | {title} | {EnvVars.SITE_NAME} 18 | 19 | 20 | 21 | 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pages/pricing.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Page from 'components/Page'; 3 | import FaqSection from 'views/PricingPage/FaqSection'; 4 | import PricingTablesSection from 'views/PricingPage/PricingTablesSection'; 5 | 6 | export default function PricingPage() { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | } 16 | 17 | const Wrapper = styled.div` 18 | & > :last-child { 19 | margin-bottom: 15rem; 20 | } 21 | `; 22 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /views/ContactPage/InformationSection.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | export default function InformationSection() { 4 | return ( 5 | 6 |

Contact Info

7 |

8 | Email: support@myawesomesaas.com 9 |

10 |
11 | ); 12 | } 13 | 14 | const Wrapper = styled.div` 15 | flex: 1; 16 | margin-right: 3rem; 17 | margin-bottom: 3rem; 18 | 19 | h3 { 20 | font-size: 2.5rem; 21 | margin-bottom: 2rem; 22 | } 23 | 24 | p { 25 | font-weight: normal; 26 | font-size: 1.6rem; 27 | color: rgba(var(--text), 0.7); 28 | } 29 | 30 | span { 31 | opacity: 1; 32 | color: rgba(var(--text), 1); 33 | } 34 | `; 35 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /components/HamburgerIcon.tsx: -------------------------------------------------------------------------------- 1 | import Icon, { IconProps } from './Icon' 2 | 3 | export function HamburgerIcon(props: IconProps) { 4 | return ( 5 | 6 | 17 | 18 | ) 19 | } 20 | -------------------------------------------------------------------------------- /pages/contact.tsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import Page from 'components/Page'; 3 | import { media } from 'utils/media'; 4 | import FormSection from 'views/ContactPage/FormSection'; 5 | import InformationSection from 'views/ContactPage/InformationSection'; 6 | 7 | export default function ContactPage() { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | ); 16 | } 17 | 18 | const ContactContainer = styled.div` 19 | display: flex; 20 | 21 | ${media('<=tablet')} { 22 | flex-direction: column; 23 | } 24 | `; 25 | -------------------------------------------------------------------------------- /.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 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /pages/api/sendEmail.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from 'next'; 2 | 3 | const sgMail = require('@sendgrid/mail'); 4 | 5 | export default async function SendEmail(req: NextApiRequest, res: NextApiResponse) { 6 | sgMail.setApiKey(process.env.SENDGRID_API_KEY); 7 | 8 | const { subject, description, email, name } = req.body; 9 | const referer = req.headers.referer; 10 | 11 | const content = { 12 | to: ['contact@bstefanski.com'], 13 | from: 'contact@bstefanski.com', 14 | subject: subject, 15 | text: description, 16 | html: `
17 |

Name: ${name}

18 |

E-mail: ${email}

19 |

${description}

20 |

Sent from: ${referer || 'Not specified or hidden'}`, 21 | }; 22 | 23 | try { 24 | await sgMail.send(content); 25 | res.status(204).end(); 26 | } catch (error) { 27 | console.log('ERROR', error); 28 | res.status(400).send({ message: error }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /utils/postsFetcher.ts: -------------------------------------------------------------------------------- 1 | import matter from 'gray-matter'; 2 | import * as fs from 'fs'; 3 | import * as path from 'path'; 4 | import { SingleArticle } from 'types'; 5 | 6 | export async function getAllPosts() { 7 | return Promise.all(getAllPostsSlugs().map(getSinglePost)); 8 | } 9 | 10 | export function getAllPostsSlugs() { 11 | return fs.readdirSync(getPostsDirectory()).map(normalizePostName); 12 | } 13 | 14 | function normalizePostName(postName: string) { 15 | return postName.replace('.mdx', ''); 16 | } 17 | 18 | export async function getSinglePost(slug: string): Promise { 19 | const filePath = path.join(getPostsDirectory(), slug + '.mdx'); 20 | const contents = fs.readFileSync(filePath, 'utf8'); 21 | const { data: meta, content } = matter(contents); 22 | 23 | return { slug, content, meta: meta as SingleArticle['meta'] }; 24 | } 25 | 26 | export function getPostsDirectory() { 27 | let basePath = process.cwd(); 28 | return path.join(basePath, 'posts'); 29 | } 30 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /public/testimonials/company-logo-2.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import Document, { DocumentContext, Head, Html, Main, NextScript } from 'next/document'; 2 | import React from 'react'; 3 | import { ServerStyleSheet } from 'styled-components'; 4 | 5 | export default class MyDocument extends Document { 6 | static async getInitialProps(ctx: DocumentContext) { 7 | const sheet = new ServerStyleSheet(); 8 | const originalRenderPage = ctx.renderPage; 9 | 10 | try { 11 | ctx.renderPage = () => 12 | originalRenderPage({ 13 | enhanceApp: (App: any) => (props) => sheet.collectStyles(), 14 | }); 15 | 16 | const initialProps = await Document.getInitialProps(ctx); 17 | return { 18 | ...initialProps, 19 | styles: ( 20 | <> 21 | {initialProps.styles} 22 | {sheet.getStyleElement()} 23 | 24 | ), 25 | }; 26 | } finally { 27 | sheet.seal(); 28 | } 29 | } 30 | 31 | render() { 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 |
39 | 40 | 41 | 42 | ); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /views/SingleArticlePage/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import ArticleImage from 'components/ArticleImage'; 4 | import { media } from 'utils/media'; 5 | 6 | interface HeaderProps { 7 | title: string; 8 | formattedDate: string; 9 | imageUrl: string; 10 | readTime: string; 11 | } 12 | 13 | export default function Header({ title, formattedDate, imageUrl, readTime }: HeaderProps) { 14 | return ( 15 | 16 | 17 | {title} 18 | 19 | {formattedDate} {readTime} 20 | 21 | 22 | ); 23 | } 24 | 25 | const HeaderContainer = styled.div` 26 | display: flex; 27 | flex-direction: column; 28 | max-width: 90rem; 29 | margin-bottom: 8rem; 30 | `; 31 | 32 | const Title = styled.h1` 33 | font-weight: 600; 34 | font-size: 4.8rem; 35 | line-height: 5.6rem; 36 | margin-bottom: 28px; 37 | 38 | ${media('<=tablet')} { 39 | font-size: 3.5rem; 40 | line-height: 4.8rem; 41 | } 42 | `; 43 | 44 | const DetailsContainer = styled.div` 45 | font-size: 1.6rem; 46 | color: var(--text-lighter); 47 | `; 48 | 49 | const MidDot = styled.span` 50 | &::before { 51 | display: inline-block; 52 | content: '\x000B7'; 53 | margin: 0 0.6rem; 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /views/SingleArticlePage/ShareWidget.tsx: -------------------------------------------------------------------------------- 1 | import { FacebookIcon, FacebookShareButton, LinkedinIcon, LinkedinShareButton, TwitterIcon, TwitterShareButton } from 'react-share'; 2 | import styled from 'styled-components'; 3 | import { EnvVars } from 'env'; 4 | import { media } from 'utils/media'; 5 | 6 | interface ShareWidgetProps { 7 | title: string; 8 | slug: string; 9 | } 10 | 11 | export default function ShareWidget({ title, slug }: ShareWidgetProps) { 12 | const shareMessage = 'New article: ' + title; 13 | const currentUrl = EnvVars.URL + 'blog/' + slug; 14 | 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | ); 28 | } 29 | 30 | const Wrapper = styled.div` 31 | display: flex; 32 | width: fit-content; 33 | flex-direction: column; 34 | position: sticky; 35 | align-items: flex-start; 36 | margin-left: -10rem; 37 | margin-top: -22.4rem; 38 | top: 50%; 39 | z-index: var(--z-sticky); 40 | transform: translateY(-50%); 41 | 42 | & > *:not(:first-child) { 43 | margin-top: 2rem; 44 | } 45 | 46 | ${media('<=largeDesktop')} { 47 | transform: translateY(-50%) scale(0.8); 48 | } 49 | 50 | ${media('<=desktop')} { 51 | display: none; 52 | } 53 | `; 54 | -------------------------------------------------------------------------------- /pages/blog/index.tsx: -------------------------------------------------------------------------------- 1 | import { InferGetStaticPropsType } from 'next'; 2 | import styled from 'styled-components'; 3 | import ArticleCard from 'components/ArticleCard'; 4 | import AutofitGrid from 'components/AutofitGrid'; 5 | import Page from 'components/Page'; 6 | import { media } from 'utils/media'; 7 | import { getAllPosts } from 'utils/postsFetcher'; 8 | 9 | export default function BlogIndexPage({ posts }: InferGetStaticPropsType) { 10 | return ( 11 | 15 | 16 | {posts.map((singlePost, idx) => ( 17 | 24 | ))} 25 | 26 | 27 | ); 28 | } 29 | 30 | const CustomAutofitGrid = styled(AutofitGrid)` 31 | --autofit-grid-item-size: 40rem; 32 | 33 | ${media('<=tablet')} { 34 | --autofit-grid-item-size: 30rem; 35 | } 36 | 37 | ${media('<=phone')} { 38 | --autofit-grid-item-size: 100%; 39 | } 40 | 41 | .article-card-wrapper { 42 | max-width: 100%; 43 | } 44 | `; 45 | 46 | export async function getStaticProps() { 47 | return { 48 | props: { 49 | posts: await getAllPosts(), 50 | }, 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /views/SingleArticlePage/OpenGraphHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import React from 'react'; 3 | import { EnvVars } from 'env'; 4 | 5 | interface OpenGraphHeadProps { 6 | slug: string; 7 | title: string; 8 | date: string; 9 | description: string; 10 | tags: string; 11 | author: string; 12 | } 13 | 14 | export default function OpenGraphHead(props: OpenGraphHeadProps) { 15 | const { slug, title, description, date, tags } = props; 16 | 17 | const currentUrl = EnvVars.URL + 'blog/' + slug; 18 | const ogImageUrl = EnvVars.OG_IMAGES_URL + `${slug}.png`; 19 | const domainName = EnvVars.URL.replace('https://', ''); 20 | 21 | return ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /views/PricingPage/PricingTablesSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import AutofitGrid from 'components/AutofitGrid'; 4 | import PricingCard from 'components/PricingCard'; 5 | import SectionTitle from 'components/SectionTitle'; 6 | 7 | export default function PricingTablesSection() { 8 | return ( 9 | 10 | Flexible pricing for agile teams 11 | 12 | 17 | $0/month 18 | 19 | 25 | $29/month 26 | 27 | 40 | $79/month 41 | 42 | 43 | 44 | ); 45 | } 46 | 47 | const Wrapper = styled.div` 48 | & > *:not(:first-child) { 49 | margin-top: 8rem; 50 | } 51 | `; 52 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /views/SingleArticlePage/StructuredDataHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from 'next/head'; 2 | import { jsonLdScriptProps } from 'react-schemaorg'; 3 | import { TechArticle, WebSite } from 'schema-dts'; 4 | import { EnvVars } from 'env'; 5 | 6 | interface StructuredDataHeadProps { 7 | slug: string; 8 | title: string; 9 | date: string; 10 | description: string; 11 | tags: string; 12 | author: string; 13 | } 14 | 15 | export default function StructuredDataHead(props: StructuredDataHeadProps) { 16 | const { slug, title, date, description, tags, author } = props; 17 | 18 | const currentSiteUrl = EnvVars.URL + 'blog/' + slug; 19 | const ogImageUrl = EnvVars.OG_IMAGES_URL + `${slug}.png`; 20 | const domainName = EnvVars.URL.replace('https://', ''); 21 | const logoUrl = EnvVars.URL + 'logo.png'; 22 | 23 | return ( 24 | 25 | */} 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 65 | {(livePageProps: any) => } 66 | 67 | } 68 | > 69 | 70 | 71 | 72 |