├── .env.development ├── .env.example ├── .eslintrc.json ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── app ├── (landing-page) │ ├── components │ │ ├── faq.tsx │ │ ├── features.tsx │ │ ├── footer.tsx │ │ ├── header.tsx │ │ ├── hero.tsx │ │ ├── mobile-menu.tsx │ │ ├── newsletter.tsx │ │ ├── page-illustration.tsx │ │ ├── pricing.tsx │ │ ├── testimonials.tsx │ │ └── zigzag.tsx │ ├── layout.tsx │ ├── page.tsx │ ├── public │ │ ├── images │ │ │ ├── features-03-image-01.png │ │ │ ├── features-03-image-02.png │ │ │ ├── features-03-image-03.png │ │ │ ├── hero-image-01.jpg │ │ │ ├── logo.svg │ │ │ ├── testimonial-01.jpg │ │ │ ├── testimonial-02.jpg │ │ │ └── testimonial-03.jpg │ │ └── videos │ │ │ └── video.mp4 │ └── style.css ├── account │ ├── layout.tsx │ └── page.tsx ├── api │ ├── [...nextauth] │ │ └── route.ts │ ├── impersonate │ │ └── route.ts │ └── payment │ │ ├── checkout_sessions │ │ └── route.ts │ │ └── webhook │ │ └── route.ts ├── billing │ ├── layout.tsx │ └── page.tsx ├── dashboard │ ├── (overview) │ │ ├── loading.tsx │ │ └── page.tsx │ └── layout.tsx ├── favicon.ico ├── layout.tsx ├── lib │ ├── placeholder-data.ts │ └── utils.ts ├── login │ ├── impersonate │ │ └── page.tsx │ └── page.tsx ├── opengraph-image.png └── ui │ ├── acme-logo.tsx │ ├── breadcrumbs.tsx │ ├── button.tsx │ ├── dashboard │ ├── nav-links.tsx │ └── sidenav.tsx │ ├── fonts.ts │ ├── global.css │ ├── login-form.tsx │ ├── logout-button.tsx │ ├── popup.tsx │ ├── search.tsx │ ├── skeletons.tsx │ └── stripe.tsx ├── auth.config.ts ├── auth.ts ├── domain ├── company │ ├── company.entity.ts │ ├── company.port.ts │ ├── company.repository.ts │ └── use-case.ts └── user │ ├── use-case.ts │ ├── user.entity.ts │ ├── user.port.ts │ └── user.repository.ts ├── infra ├── googleAnalytics.tsx ├── googleTagManager.tsx ├── mailgun.ts ├── prisma.ts ├── providerDetector.ts └── stripe.ts ├── middleware.ts ├── next.config.mjs ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma ├── README.md ├── migrations │ ├── 20240926140356_init │ │ └── migration.sql │ ├── 20240927081526_user_updated_at │ │ └── migration.sql │ ├── 20241002105605_database_adapter │ │ └── migration.sql │ ├── 20241002171805_stripe │ │ └── migration.sql │ ├── 20241007153619_company │ │ └── migration.sql │ ├── 20241008120723_transaction │ │ └── migration.sql │ ├── 20241008223030_customerremoval │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── billing-screenshot.png ├── customers │ ├── amy-burns.png │ ├── balazs-orban.png │ ├── delba-de-oliveira.png │ ├── evil-rabbit.png │ ├── lee-robinson.png │ └── michael-novotny.png ├── google-logo.svg ├── hero-desktop.png ├── hero-mobile.png ├── landing.png ├── landingFeatures.png └── sso.png ├── tailwind.config.ts └── tsconfig.json /.env.development: -------------------------------------------------------------------------------- 1 | # environment variables for the development environment 2 | # We need this in order to make the project work locally even though you forget to add the .env file but you want to see the landing page 3 | 4 | # NextAuth 5 | AUTH_SECRET=your-secret-key # ex: AUTH_SECRET=dkH0v9E+YN8WnPAqLGa4EEyuwyTvpSrXnVPeOrH25Dc= 6 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # environment variables for the project 2 | 3 | # NextAuth 4 | # Generate your own secret key for JWT encryption, typing command : `openssl rand -base64 32` 5 | AUTH_SECRET=your-secret-key # ex: AUTH_SECRET=dkH0v9E+YN8WnPAqLGa4EEyuwyTvpSrXnVPeOrH25Dc= 6 | # AUTH_URL=http://localhost:3000/api/auth 7 | 8 | # Prisma 9 | # Environment variables declared in this file are automatically made available to Prisma. 10 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 11 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 12 | DATABASE_URL="SUBSTITUTE_SUPABASE_ANON_KEY" # ex: DATABASE_URL="postgresql://username:password@ep-super-unit-1.eu-central-1.aws.neon.tech/database?sslmode=require" 13 | 14 | # SendGrid 15 | # to send emails (or if you want to develop magic link for authentication) 16 | AUTH_SENDGRID_KEY=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 17 | 18 | # Google OAuth 19 | # Have look at https://console.cloud.google.com/apis/credentials 20 | AUTH_GOOGLE_ID=your-google-Oauth2.0 # ex: AUTH_GOOGLE_ID=567651309970-23rp816jgpjmi8lfpft2qs8cq706gqkq.apps.googleusercontent.com 21 | AUTH_GOOGLE_SECRET=your-secret # ex: AUTH_GOOGLE_SECRET=JUCSPX-l_QYVXoPxr0pLVDEyNB4n3XzxAN9 22 | 23 | # Stripe 24 | NEXT_PUBLIC_STRIPE_PUBLIC_KEY=pk_12345 25 | STRIPE_SECRET_KEY=sk_12345 26 | STRIPE_SECRET_WEBHOOK_KEY=whsec_12345 27 | NEXT_BASE_URL=http://localhost:3000 28 | NEXT_PUBLIC_STRIPE_PORTAL_URL= # ex: NEXT_PUBLIC_STRIPE_PORTAL_URL="https://billing.stripe.com/p/login/test_xxxxx" 29 | 30 | # Google Analytics 31 | NEXT_PUBLIC_GOOGLE_ANALYTICS_ID= # ex: NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-123456789 32 | 33 | # email settings with Mailgun 34 | # have a look at https://app.eu.mailgun.com/settings/api_security 35 | MAILGUN_API_KEY=xxxx-xx-xx # ex : MAILGUN_API_KEY=48f648fd4229189885ca000000000000-00e4a3d5-883e27xx 36 | 37 | # Google Tag Manager 38 | NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID= # ex: NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID=GTM-XXXXXX 39 | 40 | # admin user id for impersonate security 41 | NEXT_PUBLIC_ADMIN_USER_ID=cm2mca7pk0000xc5782cbt6kl -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "next/core-web-vitals", 4 | "next/typescript" 5 | ], 6 | "rules": { 7 | // "quotes": ["error", "single"], 8 | // we want to force semicolons 9 | // "semi": ["error", "always"], 10 | // we use 2 spaces to indent our code 11 | "indent": ["error", 2], 12 | // we want to avoid extraneous spaces 13 | "no-multi-spaces": ["error"], 14 | "react/no-unescaped-entities": "off" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.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 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "always" 4 | }, 5 | "eslint.validate": ["javascript", "javascriptreact", "typescript", "typescriptreact"] 6 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Guillim 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Next.js Boilerplate for SoloPreneurs 2 | 3 | A Next.js Starter Kit to build your idea with all you need to `earn $ in 1 hour`. 4 | 5 | ![Landing Page](public/landingFeatures.png) 6 | 7 | ### Main Features 8 | 9 | - ☀️ Free 10 | - 👁️ [Landing page](https://nextjsboilerplate-blue.vercel.app/) 11 | - 🔑 [Google SSO](https://nextjsboilerplate-blue.vercel.app/login) (NextAuth) 12 | - 💰 Stripe for payments 13 | - 📂 Postgres with Prisma 14 | - 📈 Google Analytics 15 | - 📱 Responsive 16 | - 📧 Mailgun 17 | - 📦 SEO ⏳ 18 | - 📝 Blog ⏳ 19 | - 📚 Documentation ⏳ 20 | - 🔑 Customer support : impersonation 21 | - 🫂 Customer support (chatwoot or chaty) ⏳ 22 | - 🍾 Referral program ⏳ 23 | - 🛠️ Customizable 24 | 25 | ⏳: Coming soon 26 | 27 | ### Why Choose This Next.js Boilerplate 28 | 29 | As a solo founder, you need to focus on your product, not the tedious integrations with Stripe, Database, Auth, etc. This Next.js starter kit is designed to help you bypass these tasks and build and publish your product in a day. 30 | 31 | As calculated by Marc Lou, the great inspiration for this project, the time saved is huge: **22 hours** 32 | 33 | - 4 hrs to set up emails 34 | - 6 hrs designing a landing page 35 | - 4 hrs to handle Stripe webhooks 36 | - 2 hrs for SEO tags 37 | - 1 hr applying for Google Oauth 38 | - 3 hrs for DNS records 39 | - 2 hrs for protected API routes 40 | - ∞ hrs researching... 41 | 42 | ### Pricing 43 | 44 | This boilerplate is forever **Free** 45 | 46 | But if you want to support the project, you can [buy me a coffee ☕️](https://patreon.com/guillim). 47 | 48 | **Costs of Providers** 49 | 50 | Running this project should costs `0 $`. The idea is you can test 10 products without ruining yourself ! 51 | 52 | That's why I only selected providers (see [tech stack](#tech-stack) below) with a good free tier (at the time I selected them of course). 53 | 54 | ## Get Started 55 | Setup your environment : 56 | _Copy the file [.env.example](./.env.example) to [.env](.env) and fill in the variables_ 57 | 58 | #### Development 59 | Install dependencies and run the project : 60 | ```bash 61 | pnpm install 62 | npx prisma migrate dev --name init 63 | pnpm run dev 64 | ``` 65 | 66 | open [http://localhost:3000/](http://localhost:3000/) to see the result 67 | 68 | #### Production 69 | For production, we recommend using vercel (see below) 70 | ```bash 71 | pnpm run build 72 | pnpm run start 73 | ``` 74 | ## Tech Stack 75 | 76 | It's all Typescript 77 | It's the App Router type of Next.js project 78 | It uses Postgres, hosted on [Neon](https://neon.tech/) for the database, but you can change it if you want. 79 | It's documented to guide you through the process of customizing it. 80 | It's React for the frontend, with TailwindCSS for the design. 81 | It's easy to be host on [Vercel](https://vercel.com/), but you can change it. 82 | It will be automatically deployed on git push, no worries about that, focus on code. 83 | 84 | 85 | - Hosting : Vercel 86 | 87 | Visit [vercel](https://vercel.com/signup) to create an account. Choose the free "hobby" plan. Select Continue with GitHub to connect your GitHub and Vercel accounts. Read [this page](https://nextjs.org/learn/dashboard-app/setting-up-your-database) if you are stuck 88 | 89 | - 📂 Database : Neon 90 | 91 | We do not chose Vercel Postgresql because it's not free. Neon is a great alternative, and it's free. Visit [Neon](https://neon.tech/) to create an account. Choose the free plan. Read [this page](https://neon.tech/docs/guides/nextjs) 92 | 93 | We user [Prisma](https://www.prisma.io/docs/getting-started/setup-prisma/start-from-scratch/relational-databases-typescript-postgresql) to connect to the database. Very useful to be error-proof. 94 | 95 | More info in our [prisma/README.md file](prisma/README.md) 96 | 97 | - Landing Page 98 | 99 | Once you run the project, you can access the landing page at [localhost:3000](http://localhost:3000/). 100 | Feel free to customize it as you want in the folder `(landing-page)`. 101 | 102 | ![Landing Page](public/landing.png) 103 | 104 | - 🔑 Auth 105 | 106 | Google Auth is already set up. To use it. please follow this [guide](https://authjs.dev/getting-started/authentication/oauth) TLDR, it begins with creating a project on the [Google Cloud Platform](https://console.cloud.google.com/apis/credentials). 107 | 108 | We recommend reading [NextAuth](https://next-auth.js.org/getting-started/introduction) for other easy ways to authenticate users (google, twitter, github...) 109 | 110 | ![Landing Page](public/sso.png) 111 | 112 | 113 | Impersonation: For better support, you can log in as any user. Simply add your own user ID to **.env** `NEXT_PUBLIC_ADMIN_USER_ID=` and visit [/login/impersonate](/login/impersonate) 114 | 115 | - 💰 Payment : Stripe 116 | 117 | We use Stripe for payments. Please create an account here [Stripe](https://stripe.com/). 118 | To make things simpler, Stripe is related to a company, not a user here. So, for every user, a company is automatically created. 119 | 120 | For setup, read this [Stripe Tutorial](https://medium.com/@rakeshdhariwal61/integrating-stripe-payment-gateway-in-next-js-14-a-step-by-step-guide-1bd17d164c2c). Use [the Stripe test card](https://docs.stripe.com/testing) for testing. 121 | 122 | 123 | ![billing section](public/billing-screenshot.png) 124 | 125 |
126 | One click button 127 | 128 | How to integrate Stripe button ? You simply need to add this to make the payment work (just change for the correct priceId): 129 | ```react 130 | 134 | ``` 135 | This is already included in the [billing](/billing) page 136 | 137 |
138 | 139 |
140 | Customer Portal 141 | 142 | Don't worry about handling invoices and managing subscriptions. Stripe has a [customer portal](https://stripe.com/docs/billing/subscriptions/customer-portal) doing that for you. 143 | 144 | You users can connect directly with their email, it looks like this: [https://billing.stripe.com](https://billing.stripe.com/p/session/test_YWNjdF8xR3kxaUZBbHF2S3B4SkN1LF9SOEN5QTN0aGVrNFpWTHExWWNMaW1EWnE5Y29tOE1o0100dW7QNfxX) 145 | 146 | This is already included in the [billing](/billing) page 147 | 148 |
149 |
150 | 151 | - 📈 Google Analytics 152 | 153 | We use Google Analytics to track the users. Please create an account here [Google Analytics](https://analytics.google.com/). Then add your id in the [.env](./.env) file 154 | ```markdown 155 | # Google Analytics 156 | NEXT_PUBLIC_GOOGLE_ANALYTICS_ID=G-xxxxxxx 157 | ``` 158 | 159 | - ✉️ Email : Mailgun 160 | Mailgun offers 100 free emails per day which is the biggest free plan we found during our research. Create an account [here](https://signup.mailgun.com/new/signup?plan_name=dev_free¤cy=USD). 161 | - Create an API key in the [API key section](https://app.mailgun.com/settings/api_security) 162 | - Then add it to the [.env](./.env) file 163 | - then adapt this piece of code to your needs, but make sure you change 'mail.mydomain.com' to your mail domain as setup in mailgun: 164 | ```ts 165 | // On your pages/api routes: 166 | import { mailgunClientGlobal } from '@/infra/mailgun'; 167 | const mg = await mailgunClientGlobal 168 | await mg.mailgun?.messages.create( 169 | 'mail.mydomain.com', 170 | {...mg.getDefaultValues(), 171 | from: 'Excited User ', 172 | to: ['contact@mydomain.uk'] } 173 | ); 174 | ``` 175 | Note : If your mail server in not in europe, you may comment out one line here : [mailgun.ts](./infra/mailgun.ts) 176 | `url: 'https://api.eu.mailgun.net'` since it's specific for EU servers. 177 | 178 | - IDE : VScode 179 | 180 | We recommend using VScode for the project, as it's the most popular editor for web development. 181 | ESlint is automatic on save to get a better developer experience. 182 | 183 | - Code hosting : Github Repository 184 | 185 | To host your code on Github, please follow this [guide](https://help.github.com/en/github/getting-started-with-github/create-a-repo). 186 | 187 | 188 | ### Thanks 189 | 190 | It's based on the Next.js (App Router) starter template. 191 | For customisation, see the [course](https://nextjs.org/learn) on the Next.js Website. 192 | 193 | ### License 194 | MIT -------------------------------------------------------------------------------- /app/(landing-page)/components/faq.tsx: -------------------------------------------------------------------------------- 1 | import { Disclosure, DisclosureButton, DisclosurePanel } from '@headlessui/react'; 2 | import { ChevronDownIcon } from '@heroicons/react/24/outline'; 3 | 4 | const faqs = [ 5 | { 6 | question: "How does the submission process work?", 7 | answer: "First you share your product details with us, and then we submit your product across all relevant platforms. The form to submit your product detail will be shared upon successful payment. The submission process will be completed within 7 days, after which we will share a submission report with you." 8 | }, 9 | { 10 | question: "How can I get support? or provide feedback?", 11 | answer: 'Reach out to me on twitter (link in the footer)' 12 | } 13 | ]; 14 | 15 | export default function Faq() { 16 | return ( 17 |
18 |
19 |
20 |
21 | 22 |
23 |

Frequently Asked Questions

24 |

Got a question? We've got answers.

25 |
26 | 27 |
28 |
29 |
30 | {faqs.map((faq, index) => ( 31 | 32 | 33 | 34 | {faq.question} 35 | 36 | 37 | 38 | 39 | {faq.answer} 40 | 41 | 42 | ))} 43 |
44 |
45 |
46 |
47 | ); 48 | } -------------------------------------------------------------------------------- /app/(landing-page)/components/features.tsx: -------------------------------------------------------------------------------- 1 | export default function Features() { 2 | return ( 3 |
4 |
5 |
6 | 7 | {/* Section header */} 8 |
9 |

The majority our customers do not understand their workflows.

10 |

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

11 |
12 | 13 | {/* Items */} 14 |
15 | 16 | {/* 1st item */} 17 |
18 | 19 | 20 | 21 | 22 | 23 |

Instant Features

24 |

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat.

25 |
26 | 27 | {/* 2nd item */} 28 |
29 | 30 | 31 | 32 | 33 | 34 |

Instant Features

35 |

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat.

36 |
37 | 38 | {/* 3rd item */} 39 |
40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |

Instant Features

49 |

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat.

50 |
51 | 52 | {/* 4th item */} 53 |
54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 |

Instant Features

62 |

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat.

63 |
64 | 65 | {/* 5th item */} 66 |
67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 |

Instant Features

75 |

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat.

76 |
77 | 78 | {/* 6th item */} 79 |
80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 |

Instant Features

89 |

Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat.

90 |
91 | 92 |
93 | 94 |
95 |
96 |
97 | ) 98 | } 99 | -------------------------------------------------------------------------------- /app/(landing-page)/components/footer.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Link from 'next/link'; 3 | import Image from 'next/image'; 4 | import Logo from '../public/images/logo.svg'; 5 | 6 | export default function Footer() { 7 | return ( 8 | 151 | ); 152 | } 153 | -------------------------------------------------------------------------------- /app/(landing-page)/components/header.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import MobileMenu from './mobile-menu'; 3 | import Image from 'next/image'; 4 | import Logo from '../public/images/logo.svg'; 5 | 6 | export default function Header() { 7 | const showLogo = true; 8 | return ( 9 |
10 |
11 |
12 | {/* Site branding */} 13 |
14 | {/* Logo */} 15 | {showLogo && ( 16 | 17 | Cruip 24 | 25 | )} 26 |
27 | 28 | {/* Desktop navigation */} 29 | 47 | 48 | 49 | 50 |
51 |
52 |
53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /app/(landing-page)/components/hero.tsx: -------------------------------------------------------------------------------- 1 | import VideoThumb from '../public/images/hero-image-01.jpg'; 2 | import Image from 'next/image'; 3 | 4 | 5 | export default function Hero() { 6 | return ( 7 |
8 |
9 | 10 | {/* Illustration behind hero content */} 11 | 22 | 23 | {/* Hero content */} 24 |
25 | 26 | {/* Section header */} 27 |
28 |

Landing template for startups

29 |

Our landing page template works on all devices, so you only have to set it up once, and get beautiful results forever.

30 |
31 |
32 | Start free trial 33 |
34 |
35 | Learn more 36 |
37 |
38 |
39 |
40 |
41 | Modal video thumbnail 42 | 54 |
55 |
56 |
57 |
58 |
59 | ); 60 | } 61 | -------------------------------------------------------------------------------- /app/(landing-page)/components/mobile-menu.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { useState, useRef, useEffect } from 'react' 4 | import Link from 'next/link' 5 | 6 | export default function MobileMenu() { 7 | const [mobileNavOpen, setMobileNavOpen] = useState(false) 8 | 9 | const trigger = useRef(null) 10 | const mobileNav = useRef(null) 11 | 12 | // close the mobile menu on click outside 13 | useEffect(() => { 14 | const clickHandler = ({ target }: { target: EventTarget | null }): void => { 15 | if (!mobileNav.current || !trigger.current) return; 16 | if (!mobileNavOpen || mobileNav.current.contains(target as Node) || trigger.current.contains(target as Node)) return; 17 | setMobileNavOpen(false) 18 | }; 19 | document.addEventListener('click', clickHandler) 20 | return () => document.removeEventListener('click', clickHandler) 21 | }) 22 | 23 | // close the mobile menu if the esc key is pressed 24 | useEffect(() => { 25 | const keyHandler = ({ keyCode }: { keyCode: number }): void => { 26 | if (!mobileNavOpen || keyCode !== 27) return; 27 | setMobileNavOpen(false) 28 | }; 29 | document.addEventListener('keydown', keyHandler) 30 | return () => document.removeEventListener('keydown', keyHandler) 31 | }) 32 | 33 | return ( 34 |
35 | {/* Hamburger button */} 36 | 54 | 55 | {/*Mobile navigation */} 56 | 78 |
79 | ) 80 | } 81 | -------------------------------------------------------------------------------- /app/(landing-page)/components/newsletter.tsx: -------------------------------------------------------------------------------- 1 | export default function Newsletter() { 2 | return ( 3 |
4 |
5 | 6 | {/* CTA box */} 7 |
8 | 9 | {/* Background illustration */} 10 | 21 | 22 |
23 | 24 | {/* CTA content */} 25 |
26 |

Stay in the loop

27 |

Join our newsletter to get top news before anyone else.

28 |
29 | 30 | {/* CTA form */} 31 |
32 |
33 | 34 | Subscribe 35 |
36 | {/* Success message */} 37 | {/*

Thanks for subscribing!

*/} 38 |
39 | 40 |
41 | 42 |
43 | 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /app/(landing-page)/components/page-illustration.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default function PageIllustration() { 4 | return ( 5 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /app/(landing-page)/components/pricing.tsx: -------------------------------------------------------------------------------- 1 | export default function Pricing() { 2 | return ( 3 |
4 |
5 |
6 | 7 | {/* Section header */} 8 |
9 |

Pricing

10 |
11 | 12 | {/* Pricing */} 13 |
14 |
15 |
16 | {/* 1st testimonial */} 17 |
18 |
19 |
20 |
21 | {/* Content */} 22 |
23 |
24 |
50$
25 |

Everything mentionned above included, summed up below :

26 |
    27 |
  • 28 | 29 | 30 | 31 | Argument 1 32 |
  • 33 |
  • 34 | 35 | 36 | 37 | Argument 2 38 |
  • 39 |
  • 40 | 41 | 42 | 43 | Argument 3 44 |
  • 45 |
  • 46 | 47 | 48 | 49 | Argument 4 50 |
  • 51 |
52 |
53 |
54 |
55 | 56 |
57 | Get Started 58 |
59 |
60 |
61 | 62 | 63 | 64 |
65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /app/(landing-page)/components/testimonials.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | import TestimonialImage01 from '../public/images/testimonial-01.jpg'; 4 | import TestimonialImage02 from '../public/images/testimonial-02.jpg'; 5 | import TestimonialImage03 from '../public/images/testimonial-03.jpg'; 6 | 7 | export default function Testimonials() { 8 | return ( 9 |
10 |
11 |
12 | 13 | {/* Section header */} 14 |
15 |

Don't take our word for it

16 |

Vitae aliquet nec ullamcorper sit amet risus nullam eget felis semper quis lectus nulla at volutpat diam ut venenatis tellus—in ornare.

17 |
18 | 19 | {/* Testimonials */} 20 |
21 | 22 | {/* 1st testimonial */} 23 |
24 |
25 |
26 | Testimonial 01 27 | 28 | 29 | 30 |
31 |
32 |
— Open PRO lets me quickly get the insights I care about so that I can focus on my productive work. I've had Open PRO for about 24 hours now and I honestly don't know how I functioned without it before.
33 |
34 | Anastasia Dan - UX Board 35 |
36 |
37 | 38 | {/* 2nd testimonial */} 39 |
40 |
41 |
42 | Testimonial 02 43 | 44 | 45 | 46 |
47 |
48 |
— Open PRO lets me quickly get the insights I care about so that I can focus on my productive work. I've had Open PRO for about 24 hours now and I honestly don't know how I functioned without it before.
49 |
50 | Anastasia Dan - UX Board 51 |
52 |
53 | 54 | {/* 3rd testimonial */} 55 |
56 |
57 |
58 | Testimonial 03 59 | 60 | 61 | 62 |
63 |
64 |
— Open PRO lets me quickly get the insights I care about so that I can focus on my productive work. I've had Open PRO for about 24 hours now and I honestly don't know how I functioned without it before.
65 |
66 | Anastasia Dan - UX Board 67 |
68 |
69 | 70 |
71 | 72 |
73 |
74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/(landing-page)/components/zigzag.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | 3 | import FeatImage01 from '../public/images/features-03-image-01.png'; 4 | import FeatImage02 from '../public/images/features-03-image-02.png'; 5 | import FeatImage03 from '../public/images/features-03-image-03.png'; 6 | 7 | export default function Zigzag() { 8 | return ( 9 |
10 |
11 |
12 | 13 | {/* Section header */} 14 |
15 |
Reach goals that matter
16 |

One product, unlimited solutions

17 |

Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit laborum — semper quis lectus nulla.

18 |
19 | 20 | {/* Items */} 21 |
22 | 23 | {/* 1st item */} 24 |
25 | {/* Image */} 26 |
27 | Features 01 28 |
29 | {/* Content */} 30 |
31 |
32 |
More speed. Less spend
33 |

Keep projects on schedule

34 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

35 |
    36 |
  • 37 | 38 | 39 | 40 | Duis aute irure dolor in reprehenderit 41 |
  • 42 |
  • 43 | 44 | 45 | 46 | Excepteur sint occaecat 47 |
  • 48 |
  • 49 | 50 | 51 | 52 | Amet consectetur adipiscing elit 53 |
  • 54 |
55 |
56 |
57 |
58 | 59 | {/* 2nd item */} 60 |
61 | {/* Image */} 62 |
63 | Features 02 64 |
65 | {/* Content */} 66 |
67 |
68 |
More speed. Less spend
69 |

Keep projects on schedule

70 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

71 |
    72 |
  • 73 | 74 | 75 | 76 | Duis aute irure dolor in reprehenderit 77 |
  • 78 |
  • 79 | 80 | 81 | 82 | Excepteur sint occaecat 83 |
  • 84 |
  • 85 | 86 | 87 | 88 | Amet consectetur adipiscing elit 89 |
  • 90 |
91 |
92 |
93 |
94 | 95 | {/* 3rd item */} 96 |
97 | {/* Image */} 98 |
99 | Features 03 100 |
101 | {/* Content */} 102 |
103 |
104 |
More speed. Less spend
105 |

Keep projects on schedule

106 |

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.

107 |
    108 |
  • 109 | 110 | 111 | 112 | Duis aute irure dolor in reprehenderit 113 |
  • 114 |
  • 115 | 116 | 117 | 118 | Excepteur sint occaecat 119 |
  • 120 |
  • 121 | 122 | 123 | 124 | Amet consectetur adipiscing elit 125 |
  • 126 |
127 |
128 |
129 |
130 | 131 |
132 | 133 |
134 |
135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /app/(landing-page)/layout.tsx: -------------------------------------------------------------------------------- 1 | import './style.css'; 2 | import { Inter, Architects_Daughter } from 'next/font/google'; 3 | import Header from './components/header'; 4 | import Footer from './components/footer'; 5 | 6 | 7 | const inter = Inter({ 8 | subsets: ['latin'], 9 | variable: '--font-inter', 10 | display: 'swap' 11 | }); 12 | 13 | const architects_daughter = Architects_Daughter({ 14 | subsets: ['latin'], 15 | variable: '--font-architects-daughter', 16 | weight: '400', 17 | display: 'swap' 18 | }); 19 | 20 | export const metadata = { 21 | title: 'Landing Page', 22 | description: 'Landing page for your project', 23 | }; 24 | 25 | export default function RootLayout({ 26 | children, 27 | }: { 28 | children: React.ReactNode 29 | }) { 30 | return ( 31 | 32 | 33 |
34 |
35 | {children} 36 |
37 |
38 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /app/(landing-page)/page.tsx: -------------------------------------------------------------------------------- 1 | import Hero from './components/hero'; 2 | import Features from './components/features'; 3 | import Zigzag from './components/zigzag'; 4 | import Testimonials from './components/testimonials'; 5 | import Newsletter from './components/newsletter'; 6 | import Faq from './components/faq'; 7 | import Pricing from './components/pricing'; 8 | 9 | export default function Home() { 10 | return ( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /app/(landing-page)/public/images/features-03-image-01.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/images/features-03-image-01.png -------------------------------------------------------------------------------- /app/(landing-page)/public/images/features-03-image-02.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/images/features-03-image-02.png -------------------------------------------------------------------------------- /app/(landing-page)/public/images/features-03-image-03.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/images/features-03-image-03.png -------------------------------------------------------------------------------- /app/(landing-page)/public/images/hero-image-01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/images/hero-image-01.jpg -------------------------------------------------------------------------------- /app/(landing-page)/public/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /app/(landing-page)/public/images/testimonial-01.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/images/testimonial-01.jpg -------------------------------------------------------------------------------- /app/(landing-page)/public/images/testimonial-02.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/images/testimonial-02.jpg -------------------------------------------------------------------------------- /app/(landing-page)/public/images/testimonial-03.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/images/testimonial-03.jpg -------------------------------------------------------------------------------- /app/(landing-page)/public/videos/video.mp4: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/(landing-page)/public/videos/video.mp4 -------------------------------------------------------------------------------- /app/(landing-page)/style.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | 5 | /* @tailwind base; 6 | @tailwind components; 7 | @tailwind utilities; */ 8 | 9 | h1 { 10 | @apply text-5xl; 11 | font-weight: 700 12 | } 13 | h2 { 14 | @apply text-3xl; 15 | } 16 | h3 { 17 | @apply text-xl; 18 | } 19 | 20 | 21 | /* Additional Tailwind directives: https://tailwindcss.com/docs/functions-and-directives/#responsive */ 22 | @layer utilities { 23 | .rtl { 24 | direction: rtl; 25 | } 26 | } 27 | 28 | /* See Alpine.js: https://github.com/alpinejs/alpine#x-cloak */ 29 | [x-cloak=""] { 30 | display: none; 31 | } 32 | 33 | .form-input:focus, 34 | .form-textarea:focus, 35 | .form-multiselect:focus, 36 | .form-select:focus, 37 | .form-checkbox:focus, 38 | .form-radio:focus { 39 | @apply ring-0; 40 | } 41 | 42 | /* Hamburger button */ 43 | .hamburger svg > *:nth-child(1), 44 | .hamburger svg > *:nth-child(2), 45 | .hamburger svg > *:nth-child(3) { 46 | transform-origin: center; 47 | transform: rotate(0deg); 48 | } 49 | 50 | .hamburger svg > *:nth-child(1) { 51 | transition: y 0.1s 0.25s ease-in, transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19), opacity 0.1s ease-in; 52 | } 53 | 54 | .hamburger svg > *:nth-child(2) { 55 | transition: transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19); 56 | } 57 | 58 | .hamburger svg > *:nth-child(3) { 59 | transition: y 0.1s 0.25s ease-in, transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19), width 0.1s 0.25s ease-in; 60 | } 61 | 62 | .hamburger.active svg > *:nth-child(1) { 63 | opacity: 0; 64 | y: 11; 65 | transform: rotate(225deg); 66 | transition: y 0.1s ease-out, transform 0.22s 0.12s cubic-bezier(0.215, 0.61, 0.355, 1), opacity 0.1s 0.12s ease-out; 67 | } 68 | 69 | .hamburger.active svg > *:nth-child(2) { 70 | transform: rotate(225deg); 71 | transition: transform 0.22s 0.12s cubic-bezier(0.215, 0.61, 0.355, 1); 72 | } 73 | 74 | .hamburger.active svg > *:nth-child(3) { 75 | y: 11; 76 | transform: rotate(135deg); 77 | transition: y 0.1s ease-out, transform 0.22s 0.12s cubic-bezier(0.215, 0.61, 0.355, 1), width 0.1s ease-out; 78 | } 79 | 80 | /* Pulsing animation */ 81 | @keyframes pulseLoop { 82 | 0% { opacity: 0; transform: scale(0.1) translateZ(0); } 83 | 40% { opacity: 1; } 84 | 60% { opacity: 1; } 85 | 100% { opacity: 0; transform: scale(2) translateZ(0); } 86 | } 87 | .pulse { 88 | transform: scale(0.1); 89 | opacity: 0; 90 | transform-origin: center; 91 | animation: pulseLoop 8000ms linear infinite; 92 | } 93 | .pulse-1 { 94 | animation-delay: -2000ms; 95 | } 96 | .pulse-2 { 97 | animation-delay: -4000ms; 98 | } 99 | .pulse-3 { 100 | animation-delay: -6000ms; 101 | } 102 | 103 | /* Custom AOS distance */ 104 | @media screen { 105 | html:not(.no-js) body [data-aos=fade-up] { 106 | -webkit-transform: translate3d(0, 10px, 0); 107 | transform: translate3d(0, 10px, 0); 108 | } 109 | 110 | html:not(.no-js) body [data-aos=fade-down] { 111 | -webkit-transform: translate3d(0, -10px, 0); 112 | transform: translate3d(0, -10px, 0); 113 | } 114 | 115 | html:not(.no-js) body [data-aos=fade-right] { 116 | -webkit-transform: translate3d(-10px, 0, 0); 117 | transform: translate3d(-10px, 0, 0); 118 | } 119 | 120 | html:not(.no-js) body [data-aos=fade-left] { 121 | -webkit-transform: translate3d(10px, 0, 0); 122 | transform: translate3d(10px, 0, 0); 123 | } 124 | 125 | html:not(.no-js) body [data-aos=fade-up-right] { 126 | -webkit-transform: translate3d(-10px, 10px, 0); 127 | transform: translate3d(-10px, 10px, 0); 128 | } 129 | 130 | html:not(.no-js) body [data-aos=fade-up-left] { 131 | -webkit-transform: translate3d(10px, 10px, 0); 132 | transform: translate3d(10px, 10px, 0); 133 | } 134 | 135 | html:not(.no-js) body [data-aos=fade-down-right] { 136 | -webkit-transform: translate3d(-10px, -10px, 0); 137 | transform: translate3d(-10px, -10px, 0); 138 | } 139 | 140 | html:not(.no-js) body [data-aos=fade-down-left] { 141 | -webkit-transform: translate3d(10px, -10px, 0); 142 | transform: translate3d(10px, -10px, 0); 143 | } 144 | 145 | html:not(.no-js) body [data-aos=zoom-in-up] { 146 | -webkit-transform: translate3d(0, 10px, 0) scale(.6); 147 | transform: translate3d(0, 10px, 0) scale(.6); 148 | } 149 | 150 | html:not(.no-js) body [data-aos=zoom-in-down] { 151 | -webkit-transform: translate3d(0, -10px, 0) scale(.6); 152 | transform: translate3d(0, -10px, 0) scale(.6); 153 | } 154 | 155 | html:not(.no-js) body [data-aos=zoom-in-right] { 156 | -webkit-transform: translate3d(-10px, 0, 0) scale(.6); 157 | transform: translate3d(-10px, 0, 0) scale(.6); 158 | } 159 | 160 | html:not(.no-js) body [data-aos=zoom-in-left] { 161 | -webkit-transform: translate3d(10px, 0, 0) scale(.6); 162 | transform: translate3d(10px, 0, 0) scale(.6); 163 | } 164 | 165 | html:not(.no-js) body [data-aos=zoom-out-up] { 166 | -webkit-transform: translate3d(0, 10px, 0) scale(1.2); 167 | transform: translate3d(0, 10px, 0) scale(1.2); 168 | } 169 | 170 | html:not(.no-js) body [data-aos=zoom-out-down] { 171 | -webkit-transform: translate3d(0, -10px, 0) scale(1.2); 172 | transform: translate3d(0, -10px, 0) scale(1.2); 173 | } 174 | 175 | html:not(.no-js) body [data-aos=zoom-out-right] { 176 | -webkit-transform: translate3d(-10px, 0, 0) scale(1.2); 177 | transform: translate3d(-10px, 0, 0) scale(1.2); 178 | } 179 | 180 | html:not(.no-js) body [data-aos=zoom-out-left] { 181 | -webkit-transform: translate3d(10px, 0, 0) scale(1.2); 182 | transform: translate3d(10px, 0, 0) scale(1.2); 183 | } 184 | } 185 | 186 | 187 | /* Range slider */ 188 | :root { 189 | --range-thumb-size: 36px; 190 | } 191 | 192 | input[type=range] { 193 | appearance: none; 194 | background: #ccc; 195 | border-radius: 3px; 196 | height: 6px; 197 | margin-top: (--range-thumb-size - 6px) * 0.5; 198 | margin-bottom: (--range-thumb-size - 6px) * 0.5; 199 | --thumb-size: #{--range-thumb-size}; 200 | } 201 | 202 | input[type=range]::-webkit-slider-thumb { 203 | appearance: none; 204 | -webkit-appearance: none; 205 | background-color: #000; 206 | background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 .5v7L12 4zM0 4l4 3.5v-7z' fill='%23FFF' fill-rule='nonzero'/%3E%3C/svg%3E"); 207 | background-position: center; 208 | background-repeat: no-repeat; 209 | border: 0; 210 | border-radius: 50%; 211 | cursor: pointer; 212 | height: --range-thumb-size; 213 | width: --range-thumb-size; 214 | } 215 | 216 | input[type=range]::-moz-range-thumb { 217 | background-color: #000; 218 | background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 .5v7L12 4zM0 4l4 3.5v-7z' fill='%23FFF' fill-rule='nonzero'/%3E%3C/svg%3E"); 219 | background-position: center; 220 | background-repeat: no-repeat; 221 | border: 0; 222 | border: none; 223 | border-radius: 50%; 224 | cursor: pointer; 225 | height: --range-thumb-size; 226 | width: --range-thumb-size; 227 | } 228 | 229 | input[type=range]::-ms-thumb { 230 | background-color: #000; 231 | background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M8 .5v7L12 4zM0 4l4 3.5v-7z' fill='%23FFF' fill-rule='nonzero'/%3E%3C/svg%3E"); 232 | background-position: center; 233 | background-repeat: no-repeat; 234 | border: 0; 235 | border-radius: 50%; 236 | cursor: pointer; 237 | height: --range-thumb-size; 238 | width: --range-thumb-size; 239 | } 240 | 241 | input[type=range]::-moz-focus-outer { 242 | border: 0; 243 | } 244 | 245 | .form-input:focus, 246 | .form-textarea:focus, 247 | .form-multiselect:focus, 248 | .form-select:focus, 249 | .form-checkbox:focus, 250 | .form-radio:focus { 251 | @apply ring-0; 252 | } 253 | 254 | /* Hamburger button */ 255 | .hamburger svg > *:nth-child(1), 256 | .hamburger svg > *:nth-child(2), 257 | .hamburger svg > *:nth-child(3) { 258 | transform-origin: center; 259 | transform: rotate(0deg); 260 | } 261 | 262 | .hamburger svg > *:nth-child(1) { 263 | transition: y 0.1s 0.25s ease-in, transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19), opacity 0.1s ease-in; 264 | } 265 | 266 | .hamburger svg > *:nth-child(2) { 267 | transition: transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19); 268 | } 269 | 270 | .hamburger svg > *:nth-child(3) { 271 | transition: y 0.1s 0.25s ease-in, transform 0.22s cubic-bezier(0.55, 0.055, 0.675, 0.19), width 0.1s 0.25s ease-in; 272 | } 273 | 274 | .hamburger.active svg > *:nth-child(1) { 275 | opacity: 0; 276 | y: 11; 277 | transform: rotate(225deg); 278 | transition: y 0.1s ease-out, transform 0.22s 0.12s cubic-bezier(0.215, 0.61, 0.355, 1), opacity 0.1s 0.12s ease-out; 279 | } 280 | 281 | .hamburger.active svg > *:nth-child(2) { 282 | transform: rotate(225deg); 283 | transition: transform 0.22s 0.12s cubic-bezier(0.215, 0.61, 0.355, 1); 284 | } 285 | 286 | .hamburger.active svg > *:nth-child(3) { 287 | y: 11; 288 | transform: rotate(135deg); 289 | transition: y 0.1s ease-out, transform 0.22s 0.12s cubic-bezier(0.215, 0.61, 0.355, 1), width 0.1s ease-out; 290 | } 291 | 292 | /* Pulsing animation */ 293 | @keyframes pulseLoop { 294 | 0% { opacity: 0; transform: scale(0.1) translateZ(0); } 295 | 40% { opacity: 1; } 296 | 60% { opacity: 1; } 297 | 100% { opacity: 0; transform: scale(2) translateZ(0); } 298 | } 299 | .pulse { 300 | transform: scale(0.1); 301 | opacity: 0; 302 | transform-origin: center; 303 | animation: pulseLoop 8000ms linear infinite; 304 | } 305 | .pulse-1 { 306 | animation-delay: -2000ms; 307 | } 308 | .pulse-2 { 309 | animation-delay: -4000ms; 310 | } 311 | .pulse-3 { 312 | animation-delay: -6000ms; 313 | } 314 | 315 | /* Custom AOS distance */ 316 | @media screen { 317 | html:not(.no-js) body [data-aos=fade-up] { 318 | -webkit-transform: translate3d(0, 10px, 0); 319 | transform: translate3d(0, 10px, 0); 320 | } 321 | 322 | html:not(.no-js) body [data-aos=fade-down] { 323 | -webkit-transform: translate3d(0, -10px, 0); 324 | transform: translate3d(0, -10px, 0); 325 | } 326 | 327 | html:not(.no-js) body [data-aos=fade-right] { 328 | -webkit-transform: translate3d(-10px, 0, 0); 329 | transform: translate3d(-10px, 0, 0); 330 | } 331 | 332 | html:not(.no-js) body [data-aos=fade-left] { 333 | -webkit-transform: translate3d(10px, 0, 0); 334 | transform: translate3d(10px, 0, 0); 335 | } 336 | 337 | html:not(.no-js) body [data-aos=fade-up-right] { 338 | -webkit-transform: translate3d(-10px, 10px, 0); 339 | transform: translate3d(-10px, 10px, 0); 340 | } 341 | 342 | html:not(.no-js) body [data-aos=fade-up-left] { 343 | -webkit-transform: translate3d(10px, 10px, 0); 344 | transform: translate3d(10px, 10px, 0); 345 | } 346 | 347 | html:not(.no-js) body [data-aos=fade-down-right] { 348 | -webkit-transform: translate3d(-10px, -10px, 0); 349 | transform: translate3d(-10px, -10px, 0); 350 | } 351 | 352 | html:not(.no-js) body [data-aos=fade-down-left] { 353 | -webkit-transform: translate3d(10px, -10px, 0); 354 | transform: translate3d(10px, -10px, 0); 355 | } 356 | 357 | html:not(.no-js) body [data-aos=zoom-in-up] { 358 | -webkit-transform: translate3d(0, 10px, 0) scale(.6); 359 | transform: translate3d(0, 10px, 0) scale(.6); 360 | } 361 | 362 | html:not(.no-js) body [data-aos=zoom-in-down] { 363 | -webkit-transform: translate3d(0, -10px, 0) scale(.6); 364 | transform: translate3d(0, -10px, 0) scale(.6); 365 | } 366 | 367 | html:not(.no-js) body [data-aos=zoom-in-right] { 368 | -webkit-transform: translate3d(-10px, 0, 0) scale(.6); 369 | transform: translate3d(-10px, 0, 0) scale(.6); 370 | } 371 | 372 | html:not(.no-js) body [data-aos=zoom-in-left] { 373 | -webkit-transform: translate3d(10px, 0, 0) scale(.6); 374 | transform: translate3d(10px, 0, 0) scale(.6); 375 | } 376 | 377 | html:not(.no-js) body [data-aos=zoom-out-up] { 378 | -webkit-transform: translate3d(0, 10px, 0) scale(1.2); 379 | transform: translate3d(0, 10px, 0) scale(1.2); 380 | } 381 | 382 | html:not(.no-js) body [data-aos=zoom-out-down] { 383 | -webkit-transform: translate3d(0, -10px, 0) scale(1.2); 384 | transform: translate3d(0, -10px, 0) scale(1.2); 385 | } 386 | 387 | html:not(.no-js) body [data-aos=zoom-out-right] { 388 | -webkit-transform: translate3d(-10px, 0, 0) scale(1.2); 389 | transform: translate3d(-10px, 0, 0) scale(1.2); 390 | } 391 | 392 | html:not(.no-js) body [data-aos=zoom-out-left] { 393 | -webkit-transform: translate3d(10px, 0, 0) scale(1.2); 394 | transform: translate3d(10px, 0, 0) scale(1.2); 395 | } 396 | } 397 | 398 | 399 | .btn,.btn-sm { 400 | display: inline-flex; 401 | align-items: center; 402 | justify-content: center; 403 | white-space: nowrap; 404 | border-radius: .5rem; 405 | font-size: .875rem; 406 | line-height: 1.5715; 407 | font-weight: 500; 408 | transition-property: all; 409 | transition-timing-function: cubic-bezier(.4,0,.2,1); 410 | transition-duration: .15s 411 | } 412 | 413 | .btn { 414 | padding: 11px 1rem 415 | } 416 | 417 | .btn-sm { 418 | padding: 7px .75rem 419 | } 420 | 421 | .rounded-2xl { 422 | border-radius: 1rem 423 | } 424 | 425 | .rounded-\[1\.25rem\] { 426 | border-radius: 1.25rem 427 | } 428 | 429 | .rounded-\[inherit\] { 430 | border-radius: inherit 431 | } 432 | 433 | .rounded-full { 434 | border-radius: 9999px 435 | } 436 | 437 | -------------------------------------------------------------------------------- /app/account/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | import Breadcrumbs from '../ui/breadcrumbs'; 3 | 4 | export default function Layout({ children }: { children: React.ReactNode }) { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | 12 | {children} 13 |
14 |
15 | ); 16 | } -------------------------------------------------------------------------------- /app/account/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth'; 2 | import { lusitana } from '../ui/fonts'; 3 | 4 | export default async function Page() { 5 | const user = await auth(); 6 | 7 | return ( 8 |
9 | 10 |

Account

11 |
12 |
You are logged in as {user?.user?.email}
13 |
14 | 15 | 16 |
17 | ); 18 | } -------------------------------------------------------------------------------- /app/api/[...nextauth]/route.ts: -------------------------------------------------------------------------------- 1 | import { handlers } from "@/auth"; 2 | 3 | export const { GET, POST } = handlers 4 | -------------------------------------------------------------------------------- /app/api/impersonate/route.ts: -------------------------------------------------------------------------------- 1 | import { NextResponse } from 'next/server'; 2 | import { prismaClientGlobal } from '@/infra/prisma'; 3 | import { auth, signIn } from '@/auth'; 4 | 5 | export async function POST(request: Request) { 6 | try { 7 | const { userEmail } = await request.json(); 8 | 9 | // Get current session 10 | const session = await auth(); 11 | if (!session?.user?.id) { 12 | return NextResponse.json( 13 | { error: 'Not authenticated' }, 14 | { status: 401 } 15 | ); 16 | } 17 | 18 | // Verify admin user 19 | const ADMIN_USER_ID = process.env.NEXT_PUBLIC_ADMIN_USER_ID; 20 | if (!ADMIN_USER_ID) { 21 | return NextResponse.json( 22 | { error: 'Admin user not configured' }, 23 | { status: 500 } 24 | ); 25 | } 26 | 27 | if (session.user.id !== ADMIN_USER_ID) { 28 | return NextResponse.json( 29 | { error: 'Not authorized' }, 30 | { status: 403 } 31 | ); 32 | } 33 | 34 | // Find the target user 35 | const user = await prismaClientGlobal.user.findUnique({ 36 | where: { email: userEmail }, 37 | }); 38 | 39 | if (!user) { 40 | return NextResponse.json( 41 | { error: 'User not found' }, 42 | { status: 404 } 43 | ); 44 | } 45 | 46 | // Create session for the user 47 | await signIn('credentials', { 48 | email: user.email, 49 | id: user.id, 50 | redirect: false, 51 | }); 52 | 53 | return NextResponse.json({ success: true }); 54 | } catch (error) { 55 | console.error('Admin login error:', error); 56 | return NextResponse.json( 57 | { error: 'Internal server error' }, 58 | { status: 500 } 59 | ); 60 | } 61 | } -------------------------------------------------------------------------------- /app/api/payment/checkout_sessions/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | import { auth } from '@/auth'; 4 | import { GetUser } from '@/domain/user/use-case'; 5 | 6 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY as string); 7 | 8 | 9 | export async function POST(request: NextRequest) { 10 | try { 11 | const user = await auth(); 12 | if(!user || !user?.user?.id) return new NextResponse('User not detected', { status: 500 }); 13 | const getUser = new GetUser() 14 | const userInfo = await getUser.getUserById(user.user?.id as string) 15 | // you can implement some basic check here like, is user valid or not 16 | const data = await request.json(); 17 | const priceId = data.priceId; 18 | const checkoutSession: Stripe.Checkout.Session = 19 | await stripe.checkout.sessions.create({ 20 | payment_method_types: ['card'], 21 | line_items: [ 22 | { 23 | price: priceId, 24 | quantity: 1 25 | } 26 | ], 27 | mode: 'payment', 28 | success_url: `${process.env.NEXT_BASE_URL}/billing?success=true`, 29 | cancel_url: `${process.env.NEXT_BASE_URL}/billing?canceled=true`, 30 | metadata: { 31 | userId: user.user?.id, 32 | companyId: userInfo?.props?.companyId || null, 33 | priceId 34 | } 35 | }); 36 | return NextResponse.json({ result: checkoutSession, ok: true }); 37 | } catch (error) { 38 | console.log(error); 39 | return new NextResponse('Internal Server', { status: 500 }); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /app/api/payment/webhook/route.ts: -------------------------------------------------------------------------------- 1 | import Stripe from 'stripe'; 2 | import { NextRequest } from 'next/server'; 3 | import { headers } from 'next/headers'; 4 | import { RegisterTransaction } from '@/domain/company/use-case'; 5 | import { TransactionProps, CustomerDetails } from '@/domain/company/company.entity'; 6 | 7 | type METADATA = { 8 | userId: string; 9 | priceId: string; 10 | companyId: string; 11 | }; 12 | const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!); 13 | 14 | export async function POST(request: NextRequest) { 15 | const body = await request.text(); 16 | const endpointSecret = process.env.STRIPE_SECRET_WEBHOOK_KEY!; 17 | const sig = headers().get('stripe-signature') as string; 18 | let event: Stripe.Event; 19 | try { 20 | event = stripe.webhooks.constructEvent(body, sig, endpointSecret); 21 | } catch (err) { 22 | return new Response(`Webhook Error: ${err}`, { 23 | status: 400 24 | }); 25 | } 26 | 27 | const eventType = event.type; 28 | if ( 29 | eventType !== 'checkout.session.completed' && 30 | eventType !== 'checkout.session.async_payment_succeeded' 31 | ) 32 | return new Response(`Server Error for unhandled eventType ${eventType}`, { 33 | status: 500 34 | }); 35 | const data = event.data.object 36 | const metadata = data.metadata as METADATA; 37 | const userId = metadata.userId; 38 | const priceId = metadata.priceId; 39 | const companyId = metadata.companyId; 40 | const created = data.created; 41 | const currency = data.currency; 42 | const customerDetails = data.customer_details as unknown as CustomerDetails; 43 | const amount = data.amount_total; 44 | 45 | const transactionDetails: TransactionProps = { 46 | userId, 47 | priceId, 48 | companyId, 49 | }; 50 | 51 | if (amount){ transactionDetails.amount = amount; } 52 | if (currency){ transactionDetails.currency = currency; } 53 | if (created){ transactionDetails.created = created; } 54 | if(customerDetails){ 55 | transactionDetails.customerDetails = customerDetails 56 | } 57 | 58 | 59 | try { 60 | // database update here 61 | const registerTransaction = new RegisterTransaction(); 62 | if(!transactionDetails) throw new Error('Transaction details not found'); 63 | await registerTransaction.registerTransaction(transactionDetails); 64 | // console.log('Transaction Details', transactionDetails); 65 | // here we can create a Subscription model, and add all paid user's subscription details 66 | // in order to display that in the billing section ! 67 | return new Response('Subscription added', { 68 | status: 200 69 | }); 70 | } catch (error) { 71 | console.log('Error', error); 72 | return new Response('Server error', { 73 | status: 500 74 | }); 75 | } 76 | } -------------------------------------------------------------------------------- /app/billing/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | import Breadcrumbs from '../ui/breadcrumbs'; 3 | 4 | export default function Layout({ children }: { children: React.ReactNode }) { 5 | return ( 6 |
7 |
8 | 9 |
10 |
11 | 12 | {children} 13 |
14 |
15 | ); 16 | } -------------------------------------------------------------------------------- /app/billing/page.tsx: -------------------------------------------------------------------------------- 1 | import { auth } from '@/auth'; 2 | import SubscribeComponent from '../ui/stripe'; 3 | import Popup from '../ui/popup'; 4 | import Link from 'next/link'; 5 | 6 | export default async function Page({ searchParams }: { searchParams: { [key: string]: string } }) { 7 | const user = await auth(); 8 | const success = searchParams['success'] 9 | const canceled = searchParams['canceled'] 10 | 11 | 12 | const redCross = 13 | 14 | 15 | const greenCheck = 16 | 17 | 18 | 19 | return ( 20 |
21 |

Billing

22 | 30 | {success === 'true' && 31 |
32 | Thank you {user?.user?.name} for your payment 33 |
34 | } 35 | {canceled === 'true' && 36 |
37 | Sorry {user?.user?.name}, your payment was refused 38 |
39 | } 40 |
41 | 42 |
Here, you can define your subscription plans, show your user subscirptions... And point to upgrades :)
43 |
44 |
This is also the redirect page after a purchase is made (you can change it).
45 | 46 |
47 | 48 |
49 | Example 50 |
51 |
{greenCheck} You are using the Basic version
52 |
{redCross } A Pro version adds many additional features and capabilities
53 |
54 |
55 | 56 |
Please subscribe or contact us to learn more 57 |

(You will need to change the priceId from your stripe account to make this button work)

58 |
59 | 60 | 64 |
65 | 66 |
67 | 68 |
69 |

For users with a subscription

70 | 74 | Manage monthly Plan 75 | 76 |
77 | 78 |
79 | ); 80 | } -------------------------------------------------------------------------------- /app/dashboard/(overview)/loading.tsx: -------------------------------------------------------------------------------- 1 | import DashboardSkeleton from '@/app/ui/skeletons'; 2 | 3 | export default function Loading() { 4 | return ; 5 | } -------------------------------------------------------------------------------- /app/dashboard/(overview)/page.tsx: -------------------------------------------------------------------------------- 1 | import { lusitana } from '@/app/ui/fonts'; 2 | import { auth } from '@/auth'; 3 | import { BanknotesIcon, HomeIcon } from '@heroicons/react/24/outline'; 4 | import Link from 'next/link'; 5 | 6 | export default async function Page() { 7 | const user = await auth(); 8 | const name = user?.user?.name || user?.user?.email; 9 | // await new Promise((resolve) => setTimeout( x => resolve(x), 2000)); 10 | 11 | return ( 12 |
13 |

14 | Welcome {name} 🌟 15 |

16 |
17 |

18 | You are now ready to rock. Enjoy ! 19 |

20 |
21 |
22 | 23 |
24 | 25 |

Billing

26 |
27 | 28 | 29 | 30 |
31 | 32 |

Account

33 |
34 | 35 | 36 | 37 |
38 |
39 | ); 40 | } -------------------------------------------------------------------------------- /app/dashboard/layout.tsx: -------------------------------------------------------------------------------- 1 | import SideNav from '@/app/ui/dashboard/sidenav'; 2 | import Breadcrumbs from '@/app/ui/breadcrumbs'; 3 | 4 | 5 | 6 | export default function Layout({ children }: { children: React.ReactNode }) { 7 | return ( 8 |
9 |
10 | 11 |
12 |
13 | 14 | {children} 15 |
16 |
17 | ); 18 | } -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/favicon.ico -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import '@/app/ui/global.css'; 2 | import { inter } from '@/app/ui/fonts'; 3 | import { Metadata } from 'next'; 4 | import GoogleAnalyticsWrapper from '@/infra/googleAnalytics'; 5 | import GoogleTagManagerWrapper from '@/infra/googleTagManager'; 6 | 7 | export const metadata: Metadata = { 8 | title: { 9 | template: '%s | Dashboard', 10 | default: 'default Dashboard', 11 | }, 12 | description: 'The official Next.js Course Dashboard, built with App Router.', 13 | // metadataBase: new URL('https://mywebsite.com'), 14 | }; 15 | 16 | export default function RootLayout({ 17 | children, 18 | }: { 19 | children: React.ReactNode; 20 | }) { 21 | return ( 22 | 23 | 24 | 25 | 26 | {children} 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/lib/placeholder-data.ts: -------------------------------------------------------------------------------- 1 | // This file contains placeholder data that you'll be replacing with real data in the Data Fetching chapter: 2 | // https://nextjs.org/learn/dashboard-app/fetching-data 3 | const users = [ 4 | { 5 | id: '410544b2-4001-4271-9855-fec4b6a6442a', 6 | name: 'User', 7 | email: 'user@nextmail.com' 8 | }, 9 | ]; 10 | 11 | export { users }; 12 | -------------------------------------------------------------------------------- /app/lib/utils.ts: -------------------------------------------------------------------------------- 1 | export const formatCurrency = (amount: number) => { 2 | return (amount / 100).toLocaleString('en-US', { 3 | style: 'currency', 4 | currency: 'USD', 5 | }); 6 | }; 7 | 8 | export const formatDateToLocal = ( 9 | dateStr: string, 10 | locale: string = 'en-US', 11 | ) => { 12 | const date = new Date(dateStr); 13 | const options: Intl.DateTimeFormatOptions = { 14 | day: 'numeric', 15 | month: 'short', 16 | year: 'numeric', 17 | }; 18 | const formatter = new Intl.DateTimeFormat(locale, options); 19 | return formatter.format(date); 20 | }; 21 | 22 | export const generatePagination = (currentPage: number, totalPages: number) => { 23 | // If the total number of pages is 7 or less, 24 | // display all pages without any ellipsis. 25 | if (totalPages <= 7) { 26 | return Array.from({ length: totalPages }, (_, i) => i + 1); 27 | } 28 | 29 | // If the current page is among the first 3 pages, 30 | // show the first 3, an ellipsis, and the last 2 pages. 31 | if (currentPage <= 3) { 32 | return [1, 2, 3, '...', totalPages - 1, totalPages]; 33 | } 34 | 35 | // If the current page is among the last 3 pages, 36 | // show the first 2, an ellipsis, and the last 3 pages. 37 | if (currentPage >= totalPages - 2) { 38 | return [1, 2, '...', totalPages - 2, totalPages - 1, totalPages]; 39 | } 40 | 41 | // If the current page is somewhere in the middle, 42 | // show the first page, an ellipsis, the current page and its neighbors, 43 | // another ellipsis, and the last page. 44 | return [ 45 | 1, 46 | '...', 47 | currentPage - 1, 48 | currentPage, 49 | currentPage + 1, 50 | '...', 51 | totalPages, 52 | ]; 53 | }; 54 | -------------------------------------------------------------------------------- /app/login/impersonate/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useState } from 'react'; 4 | import { useRouter } from 'next/navigation'; 5 | 6 | export default function AdminLoginPage() { 7 | const [userEmail, setUserEmail] = useState(''); 8 | const [error, setError] = useState(''); 9 | const router = useRouter(); 10 | 11 | const handleSubmit = async (e: React.FormEvent) => { 12 | e.preventDefault(); 13 | setError(''); 14 | 15 | try { 16 | const response = await fetch('/api/impersonate', { 17 | method: 'POST', 18 | headers: { 19 | 'Content-Type': 'application/json', 20 | }, 21 | body: JSON.stringify({ 22 | userEmail, 23 | }), 24 | }); 25 | 26 | if (!response.ok) { 27 | const data = await response.json(); 28 | throw new Error(data.error || 'Login failed'); 29 | } 30 | 31 | // If successful, redirect to dashboard 32 | router.push('/dashboard'); 33 | } catch (err: unknown) { 34 | if (err instanceof Error) { 35 | setError(err.message); 36 | } else { 37 | setError('An unexpected error occurred'); 38 | } 39 | } 40 | }; 41 | 42 | return ( 43 |
44 |
45 |
46 |

47 | Impersonate (Admin only) 48 |

49 |
50 |
51 |
52 | 55 | setUserEmail(e.target.value)} 64 | /> 65 |
66 | 67 | {error && ( 68 |
{error}
69 | )} 70 | 71 |
72 | 78 |
79 |
80 |
81 |
82 | ); 83 | } -------------------------------------------------------------------------------- /app/login/page.tsx: -------------------------------------------------------------------------------- 1 | import AcmeLogo from '@/app/ui/acme-logo'; 2 | import LoginForm from '@/app/ui/login-form'; 3 | import { auth } from '@/auth'; 4 | import { HomeIcon } from '@heroicons/react/24/outline'; 5 | import Link from 'next/link'; 6 | import LogoutButton from '../ui/logout-button'; 7 | import { providersList } from '@/infra/providerDetector'; 8 | import Image from 'next/image'; 9 | import VideoThumb from '../(landing-page)/public/images/hero-image-01.jpg'; 10 | 11 | export default async function LoginPage() { 12 | const user = await auth(); 13 | const name = user?.user?.name || user?.user?.email; 14 | return ( 15 |
16 |
17 |
18 | screenshot Image 23 |
24 |
25 |
26 |
27 | 28 |
29 |
30 | {!!user ? ( 31 |
32 | 33 | 37 | 38 |
Home
39 | 40 | 41 | 42 |
43 | ) : ( 44 | (providersList.googleAuth.isAvailable) ? 45 | : 46 |

Google Auth not configured. Have a look at /env.example to understand how to configure required services. More info on the setup here

47 | 48 | )} 49 |
50 |
51 |
52 | ); 53 | } 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/opengraph-image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/app/opengraph-image.png -------------------------------------------------------------------------------- /app/ui/acme-logo.tsx: -------------------------------------------------------------------------------- 1 | import Image from 'next/image'; 2 | import Logo from '../(landing-page)/public/images/logo.svg'; 3 | import Link from 'next/link'; 4 | 5 | export default function AcmeLogo() { 6 | return ( 7 | 8 |
11 | Logo 18 |

Acme

19 |
20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/ui/breadcrumbs.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { usePathname } from 'next/navigation' 4 | 5 | export default function Breadcrumbs() { 6 | const paths = usePathname() 7 | const pathNames = paths.split('/').filter( path => path ) 8 | 9 | return ( 10 | 33 | ); 34 | }; -------------------------------------------------------------------------------- /app/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | interface ButtonProps extends React.ButtonHTMLAttributes { 4 | children: React.ReactNode; 5 | } 6 | 7 | export function Button({ children, className, ...rest }: ButtonProps) { 8 | return ( 9 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/ui/dashboard/nav-links.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { 3 | HomeIcon, 4 | BanknotesIcon, 5 | UserIcon, 6 | } from '@heroicons/react/24/outline'; 7 | import Link from 'next/link'; 8 | import { usePathname } from 'next/navigation'; 9 | import clsx from 'clsx'; 10 | 11 | // Map of links to display in the side navigation. 12 | // Depending on the size of the application, this would be stored in a database. 13 | const links = [ 14 | { name: 'Home', href: '/dashboard', icon: HomeIcon }, 15 | { name: 'Billing', href: '/billing', icon: BanknotesIcon }, 16 | { name: 'Account', href: '/account', icon: UserIcon }, 17 | ]; 18 | 19 | export default function NavLinks() { 20 | const pathname = usePathname(); 21 | return ( 22 | <> 23 | {links.map((link) => { 24 | const LinkIcon = link.icon; 25 | return ( 26 | 36 | 37 |

{link.name}

38 | 39 | ); 40 | })} 41 | 42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/ui/dashboard/sidenav.tsx: -------------------------------------------------------------------------------- 1 | import NavLinks from '@/app/ui/dashboard/nav-links'; 2 | import AcmeLogo from '@/app/ui/acme-logo'; 3 | import { PlusIcon, PowerIcon } from '@heroicons/react/24/outline'; 4 | import { signOut, auth } from '@/auth'; 5 | import Link from 'next/link'; 6 | 7 | export default async function SideNav() { 8 | const user = await auth(); 9 | const name = user?.user?.name || user?.user?.email; 10 | return ( 11 |
12 | 15 |
16 | 17 |
18 |
19 |
20 | 21 |
22 | 24 | 25 |
Feedback
26 | 27 |
{ 28 | 'use server'; 29 | await signOut(); 30 | }}> 31 | { !!user && 32 | 36 | } 37 | 38 |
39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/ui/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Inter, Lusitana } from 'next/font/google'; 2 | 3 | export const inter = Inter({ subsets: ['latin'] }); 4 | export const lusitana = Lusitana({ 5 | weight: ['400', '700'], 6 | subsets: ['latin'] 7 | }); -------------------------------------------------------------------------------- /app/ui/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | input[type='number'] { 6 | -moz-appearance: textfield; 7 | appearance: textfield; 8 | } 9 | 10 | input[type='number']::-webkit-inner-spin-button { 11 | -webkit-appearance: none; 12 | margin: 0; 13 | } 14 | 15 | input[type='number']::-webkit-outer-spin-button { 16 | -webkit-appearance: none; 17 | margin: 0; 18 | } 19 | -------------------------------------------------------------------------------- /app/ui/login-form.tsx: -------------------------------------------------------------------------------- 1 | import { lusitana } from '@/app/ui/fonts'; 2 | import { signIn } from '@/auth'; 3 | import Image from 'next/image'; 4 | 5 | 6 | export default async function LoginForm() { 7 | return ( 8 |
{ 10 | "use server" 11 | await signIn("google") 12 | }} 13 | className="space-y-3"> 14 |
15 |

16 | Please log in to continue 17 |

18 |
19 | 25 |
26 |
27 | 28 |
29 | ); 30 | } -------------------------------------------------------------------------------- /app/ui/logout-button.tsx: -------------------------------------------------------------------------------- 1 | import { signOut } from "@/auth"; 2 | import { PowerIcon } from "@heroicons/react/24/outline"; 3 | 4 | export default function LogoutButton() { 5 | return ( 6 |
{ 8 | "use server" 9 | await signOut() 10 | }} 11 | className="space-y-3" 12 | > 13 | 21 |
22 | ); 23 | } -------------------------------------------------------------------------------- /app/ui/popup.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import { Description, Dialog, DialogPanel, DialogTitle } from '@headlessui/react' 3 | import { useState } from 'react' 4 | import { Button } from '../ui/button' 5 | import { InformationCircleIcon } from '@heroicons/react/24/outline' 6 | 7 | export default function Popup( 8 | {msg, hideIcon = false, title, btnText, btnCloseText = 'Close', className = ''}: 9 | {msg: string, hideIcon?: boolean, title: string, btnText: string , btnCloseText?: string, className?: string}) { 10 | const [isOpen, setIsOpen] = useState(false) 11 | 12 | return ( 13 | <> 14 | setIsOpen(true)} 16 | > 17 | 18 | {!hideIcon && 19 | 20 | } 21 | {btnText} 22 | 23 | 24 | setIsOpen(false)} className="relative z-50"> 25 |
26 | 27 | {title} 28 | 29 | {msg} 30 |
31 | 32 |
33 |
34 |
35 |
36 | 37 | ) 38 | } -------------------------------------------------------------------------------- /app/ui/search.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MagnifyingGlassIcon } from '@heroicons/react/24/outline'; 4 | 5 | export default function Search({ placeholder }: { placeholder: string }) { 6 | return ( 7 |
8 | 11 | 15 | 16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/ui/skeletons.tsx: -------------------------------------------------------------------------------- 1 | // Loading animation 2 | const shimmer = 3 | 'before:absolute before:inset-0 before:-translate-x-full before:animate-[shimmer_2s_infinite] before:bg-gradient-to-r before:from-transparent before:via-white/60 before:to-transparent'; 4 | 5 | export function CardSkeleton() { 6 | return ( 7 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 | ); 19 | } 20 | 21 | export function CardsSkeleton() { 22 | return ( 23 | <> 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | 32 | export function RevenueChartSkeleton() { 33 | return ( 34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 | ); 45 | } 46 | 47 | export function InvoiceSkeleton() { 48 | return ( 49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 | ); 60 | } 61 | 62 | export function LatestInvoicesSkeleton() { 63 | return ( 64 |
67 |
68 |
69 |
70 | 71 | 72 | 73 | 74 | 75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 | ); 83 | } 84 | 85 | export default function DashboardSkeleton() { 86 | return ( 87 | <> 88 |
91 |
92 | 93 | 94 | 95 | 96 |
97 |
98 | 99 | 100 |
101 | 102 | ); 103 | } 104 | 105 | export function TableRowSkeleton() { 106 | return ( 107 | 108 | {/* Customer Name and Image */} 109 | 110 |
111 |
112 |
113 |
114 | 115 | {/* Email */} 116 | 117 |
118 | 119 | {/* Amount */} 120 | 121 |
122 | 123 | {/* Date */} 124 | 125 |
126 | 127 | {/* Status */} 128 | 129 |
130 | 131 | {/* Actions */} 132 | 133 |
134 |
135 |
136 |
137 | 138 | 139 | ); 140 | } 141 | 142 | export function InvoicesMobileSkeleton() { 143 | return ( 144 |
145 |
146 |
147 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 |
160 |
161 |
162 |
163 | ); 164 | } 165 | 166 | export function InvoicesTableSkeleton() { 167 | return ( 168 |
169 |
170 |
171 |
172 | 173 | 174 | 175 | 176 | 177 | 178 |
179 | 180 | 181 | 182 | 185 | 188 | 191 | 194 | 197 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 |
183 | Customer 184 | 186 | Email 187 | 189 | Amount 190 | 192 | Date 193 | 195 | Status 196 | 201 | Edit 202 |
214 |
215 |
216 |
217 | ); 218 | } 219 | -------------------------------------------------------------------------------- /app/ui/stripe.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import axios from 'axios'; 3 | import { Button } from './button'; 4 | import { stripeInstance } from '@/infra/stripe'; 5 | 6 | type props = { 7 | priceId: string; 8 | price: string; 9 | description:string; 10 | }; 11 | 12 | const SubscribeComponent= ({ priceId }: props) => { 13 | const handleSubmit = async () => { 14 | const stripe = stripeInstance.getStripe(); 15 | if (!stripe) { 16 | return; 17 | } 18 | if (priceId === 'price_1Q6U4ZP9VWutz4pQA1UC2ilX') { 19 | console.log('You need to change the priceId to make this button work, you are currently using the default priceId'); 20 | return 21 | } 22 | try { 23 | const response = await axios.post('/api/payment/checkout_sessions', { 24 | priceId: priceId 25 | }); 26 | const data = response.data; 27 | if (!data.ok) throw new Error('Something went wrong'); 28 | await stripe.redirectToCheckout({ 29 | sessionId: data.result.id 30 | }); 31 | } catch (error) { 32 | console.log(error); 33 | } 34 | }; 35 | return ( 36 |
37 | {/* Click Below button to get {description} */} 38 | 43 |
44 | ); 45 | }; 46 | export default SubscribeComponent; -------------------------------------------------------------------------------- /auth.config.ts: -------------------------------------------------------------------------------- 1 | import type { NextAuthConfig } from 'next-auth'; 2 | import { GetUser, UpdateUser } from './domain/user/use-case'; 3 | import { CreateCompany } from './domain/company/use-case'; 4 | 5 | export const authConfig = { 6 | pages: { 7 | signIn: '/login', 8 | }, 9 | // debug: process.env.NODE_ENV !== "production" ? true : false, 10 | callbacks: { 11 | authorized({ auth, request }) { 12 | // if visiting landing page, it's ok 13 | if (request.nextUrl.pathname === '/') return true 14 | // Logged in users are authenticated, otherwise redirect to login page 15 | return (auth && !!auth.user) ? true : false; 16 | }, 17 | async jwt({ token, account, user, trigger }) { 18 | if (trigger === 'signUp') { 19 | // we create a company for this user 20 | const company = await new CreateCompany().createCompany(user.email?.split('@')[0] || `company-${new Date().toUTCString()}`); 21 | const userObj = await new GetUser().getUserById(user.id as string); 22 | if(!userObj) throw new Error('user not found') 23 | await new UpdateUser().linkCompany(userObj, company); 24 | } 25 | if (account) { 26 | token.accessToken = account.access_token 27 | token.id = user?.id 28 | } 29 | return token 30 | }, 31 | session({ session, token }) { 32 | // addding the user id from the database in order to use it in the app 33 | session.user = { 34 | ...session.user, 35 | id: token.id as string 36 | } 37 | return session 38 | }, 39 | }, 40 | session: { 41 | strategy: 'jwt' 42 | }, 43 | providers: [], // Add providers with an empty array for now 44 | } satisfies NextAuthConfig; -------------------------------------------------------------------------------- /auth.ts: -------------------------------------------------------------------------------- 1 | import { PrismaAdapter } from '@auth/prisma-adapter'; 2 | import NextAuth from 'next-auth'; 3 | import { authConfig } from './auth.config'; 4 | import Google from "next-auth/providers/google" 5 | import Credentials from "next-auth/providers/credentials" 6 | import { prismaClientGlobal } from './infra/prisma'; 7 | 8 | export const { auth, signIn, signOut, handlers } = NextAuth({ 9 | ...authConfig, 10 | providers: [ 11 | Google, 12 | Credentials({ 13 | name: 'Impersonate', 14 | credentials: { 15 | email: { label: "Email", type: "email" }, 16 | id: { label: "User ID", type: "text" }, 17 | }, 18 | async authorize(credentials) { 19 | if (!credentials?.email || !credentials?.id) { 20 | return null; 21 | } 22 | 23 | const user = await prismaClientGlobal.user.findUnique({ 24 | where: { 25 | id: credentials.id as string, 26 | email: credentials.email as string 27 | }, 28 | }); 29 | 30 | return user; 31 | }, 32 | }), 33 | ], 34 | adapter: PrismaAdapter(prismaClientGlobal), 35 | }); -------------------------------------------------------------------------------- /domain/company/company.entity.ts: -------------------------------------------------------------------------------- 1 | export interface CompanyProps { 2 | id: string; 3 | name: string; 4 | createdAt: Date; 5 | updatedAt: Date; 6 | } 7 | 8 | export interface CustomerDetails { 9 | address?: { 10 | city?: string | null; 11 | country?: string; 12 | line1?: string | null; 13 | line2?: string | null; 14 | postal_code?: string | null; 15 | state?: string | null; 16 | }; 17 | email?: string; 18 | name?: string; 19 | phone?: string | null; 20 | tax_exempt?: string; 21 | tax_ids?: string[]; 22 | } 23 | 24 | export interface TransactionProps { 25 | userId?: string; 26 | companyId?: string; 27 | priceId?: string; 28 | created?: number; 29 | currency?: string; 30 | customerDetails?: CustomerDetails; 31 | amount?: number; 32 | } 33 | 34 | export class Company { 35 | public props: CompanyProps; 36 | 37 | constructor(props: CompanyProps) { 38 | this.props = { ...props }; 39 | } 40 | 41 | id() { 42 | return this.props.id; 43 | } 44 | 45 | } -------------------------------------------------------------------------------- /domain/company/company.port.ts: -------------------------------------------------------------------------------- 1 | import { Company } from "./company.entity"; 2 | 3 | export interface CompanyPort { 4 | createCompany(name: string): Promise; 5 | 6 | } -------------------------------------------------------------------------------- /domain/company/company.repository.ts: -------------------------------------------------------------------------------- 1 | import { prismaClientGlobal } from '@/infra/prisma'; 2 | import { Company, TransactionProps } from './company.entity'; 3 | import { CompanyPort } from './company.port'; 4 | 5 | export class CompanyRepository implements CompanyPort { 6 | async createCompany(name : string): Promise { 7 | // use the prisma code here 8 | const company = await prismaClientGlobal.company.create({ 9 | data: { 10 | name: name, 11 | } 12 | }); 13 | return await this.getCompany(company.id) 14 | } 15 | 16 | async getCompany(id: string): Promise { 17 | const company = await prismaClientGlobal.company.findFirst( 18 | { 19 | where: { 20 | id: id, 21 | } 22 | }) 23 | if(!company) throw new Error('company not found') 24 | return new Company({ 25 | id: company.id, 26 | name: company.name, 27 | createdAt: company.createdAt, 28 | updatedAt: company.updatedAt, 29 | }) 30 | } 31 | 32 | async registerTransaction(transactionDetails: TransactionProps): Promise { 33 | if(!transactionDetails.companyId) throw new Error('Company id is required') 34 | await prismaClientGlobal.paymentTransaction.create({ 35 | data: { 36 | companyId: transactionDetails.companyId, 37 | raw: JSON.stringify(transactionDetails), 38 | createdAt: new Date(), 39 | updatedAt: new Date(), 40 | } 41 | }); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /domain/company/use-case.ts: -------------------------------------------------------------------------------- 1 | import { Company, TransactionProps } from './company.entity'; 2 | import { CompanyRepository } from './company.repository'; 3 | 4 | export class CreateCompany { 5 | async createCompany(name: string): Promise { 6 | const newCompany = await new CompanyRepository().createCompany(name); 7 | return newCompany; 8 | } 9 | } 10 | 11 | export class RegisterTransaction { 12 | async registerTransaction(transactionDetails: TransactionProps): Promise { 13 | await new CompanyRepository().registerTransaction(transactionDetails); 14 | } 15 | } -------------------------------------------------------------------------------- /domain/user/use-case.ts: -------------------------------------------------------------------------------- 1 | // clean architecture : use-case layer is where you want to put all your logic, because it will be easily testable and maintanalbe 2 | import { Company } from '../company/company.entity'; 3 | import { CreateCompany } from '../company/use-case'; 4 | import { User, UserProps } from './user.entity'; 5 | import { UserRepository } from './user.repository'; 6 | 7 | export class GetUser { 8 | async getUserById(id: string): Promise { 9 | const user = await new UserRepository().getUserById(id); 10 | return user; 11 | } 12 | } 13 | 14 | export class CreateUser { 15 | async createUser(user: Omit): Promise { 16 | // check if the user already exists 17 | const userFound = await new UserRepository().getUserByEmail(user.email); 18 | if (userFound) { 19 | throw new Error('User already exists'); 20 | } 21 | // before we create the user, let's create its company 22 | const company = await new CreateCompany().createCompany(user.email.split('@')[0]); 23 | const newUser = await new UserRepository().createUser(user); 24 | const newUserUpdated = await new UpdateUser().linkCompany(newUser, company); 25 | return newUserUpdated; 26 | } 27 | } 28 | 29 | // use case to find a user by email 30 | export class GetUserByEmail { 31 | async getUserByEmail(email: string): Promise { 32 | const user = await new UserRepository().getUserByEmail(email); 33 | return user; 34 | } 35 | } 36 | 37 | export class UpdateUser { 38 | async linkCompany(user: User, company: Company): Promise { 39 | const newUserProps = { ...user.props, companyId: company.id() }; 40 | const updatedUser = await new UserRepository().updateUser(user, newUserProps); 41 | return updatedUser; 42 | } 43 | } -------------------------------------------------------------------------------- /domain/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | export interface UserProps { 2 | id: string; 3 | name?: string | null; 4 | email: string; 5 | emailVerified?: Date | null; 6 | image?: string | null; 7 | companyId?: string | null; 8 | createdAt: Date; 9 | updatedAt: Date; 10 | } 11 | 12 | export class User { 13 | public props: UserProps; 14 | 15 | constructor(props: UserProps) { 16 | this.props = { ...props }; 17 | } 18 | 19 | id() { 20 | return this.props.id; 21 | } 22 | 23 | 24 | } -------------------------------------------------------------------------------- /domain/user/user.port.ts: -------------------------------------------------------------------------------- 1 | // clean architecture : port is where you want to put all your repository interfaces, this way you centralize the expectations 2 | import { User, UserProps } from './user.entity'; 3 | 4 | export interface UserPort { 5 | createUser: (user: UserProps) => Promise; 6 | // updateUser: (user: User) => Promise; 7 | // deleteUser: (id: string) => Promise; 8 | getUserById: (id: string) => Promise; 9 | getUserByEmail: (email: string) => Promise; 10 | } -------------------------------------------------------------------------------- /domain/user/user.repository.ts: -------------------------------------------------------------------------------- 1 | // clean architecture : repository layer is where you want to put all your database logic, this way you could easily change the database without changing the use-case logic 2 | // import { PrismaClient } from '@prisma/client'; 3 | import { UserPort } from './user.port'; 4 | import { User, UserProps } from './user.entity'; 5 | import { prismaClientGlobal } from '@/infra/prisma'; 6 | 7 | const prisma = prismaClientGlobal; 8 | 9 | export class UserRepository implements UserPort { 10 | async getUserById(id: string): Promise { 11 | const userRecord = await prisma.user.findUnique({ 12 | where: { id }, 13 | }); 14 | 15 | if (!userRecord) { 16 | return undefined; 17 | } 18 | 19 | return new User({ 20 | id: userRecord.id, 21 | name: userRecord.name, 22 | email: userRecord.email, 23 | updatedAt: userRecord.updatedAt, 24 | createdAt: userRecord.createdAt, 25 | emailVerified: userRecord.emailVerified, 26 | companyId: userRecord.companyId, 27 | image: userRecord.image, 28 | }); 29 | } 30 | 31 | async getUserByEmail(email: string): Promise { 32 | const userRecord = await prisma.user.findFirst({ 33 | where: { email }, 34 | }); 35 | 36 | if (!userRecord) { 37 | return undefined; 38 | } 39 | 40 | return new User({ 41 | id: userRecord.id, 42 | name: userRecord.name, 43 | email: userRecord.email, 44 | updatedAt: userRecord.updatedAt, 45 | createdAt: userRecord.createdAt, 46 | emailVerified: userRecord.emailVerified, 47 | image: userRecord.image, 48 | }); 49 | } 50 | 51 | async createUser(user: Omit): Promise { 52 | const userRecord = await prisma.user.create({ 53 | data: { 54 | name: user.name, 55 | email: user.email, 56 | updatedAt: new Date() 57 | }, 58 | }); 59 | 60 | return new User({ 61 | id: userRecord.id, 62 | name: userRecord.name, 63 | email: userRecord.email, 64 | updatedAt: userRecord.updatedAt, 65 | createdAt: userRecord.createdAt, 66 | emailVerified: userRecord.emailVerified, 67 | image: userRecord.image, 68 | }); 69 | } 70 | 71 | async updateUser(user: User, newUserProps: Omit): Promise { 72 | const userRecord = await prisma.user.update({ 73 | where: { id: user.id() }, 74 | data: { 75 | ...user.props, 76 | ...newUserProps, 77 | updatedAt: new Date(), 78 | }, 79 | }); 80 | 81 | return new User({ 82 | ...userRecord 83 | }); 84 | } 85 | 86 | } -------------------------------------------------------------------------------- /infra/googleAnalytics.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleAnalytics } from '@next/third-parties/google' 2 | import { providersList } from './providerDetector'; 3 | 4 | // create a react wrapper component around 5 | export default function GoogleAnalyticsWrapper() { 6 | return (providersList.googleAnalytics.isAvailable && 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /infra/googleTagManager.tsx: -------------------------------------------------------------------------------- 1 | import { GoogleTagManager } from '@next/third-parties/google' 2 | import { providersList } from './providerDetector' 3 | 4 | export default function GoogleTagManagerWrapper() { 5 | return ( 6 | providersList.googleTagManager?.isAvailable && 7 | 8 | ) 9 | } -------------------------------------------------------------------------------- /infra/mailgun.ts: -------------------------------------------------------------------------------- 1 | import Mailgun, { Interfaces } from 'mailgun.js'; 2 | import FormData from 'form-data'; 3 | import { providersList } from './providerDetector'; 4 | 5 | 6 | class MailgunWrapper { 7 | mailgun: Interfaces.IMailgunClient | null; 8 | constructor() { 9 | this.mailgun = null; 10 | if (providersList.mailgun.isAvailable) { 11 | this.initialize(); 12 | }else{ 13 | console.log('Mailgun not available. Missing API key'); 14 | } 15 | } 16 | // quote the line abouth api.eu if your mailgun mail server is in the US 17 | private async initialize(){ 18 | const mailgunClass = new Mailgun(FormData); 19 | this.mailgun = mailgunClass.client({ 20 | username: 'api', 21 | key: process.env.MAILGUN_API_KEY || '', 22 | url: 'https://api.eu.mailgun.net' 23 | }); 24 | } 25 | public getMailgun(){ 26 | return this.mailgun; 27 | } 28 | public getDefaultValues(){ 29 | return { 30 | from: "Paul ", 31 | subject: "Hello", 32 | to: [], 33 | text: "This is me testing emails!" 34 | // html: "

Testing some Mailgun awesomness!

" 35 | } 36 | } 37 | } 38 | 39 | // making only one instace of MailgunWrapper for the whole project 40 | const globalForMailgun = globalThis as unknown as { mailgun: MailgunWrapper } 41 | 42 | export const mailgunClientGlobal = globalForMailgun.mailgun || new MailgunWrapper() 43 | 44 | if (process.env.NODE_ENV !== "production") globalForMailgun.mailgun = mailgunClientGlobal 45 | 46 | 47 | // how to use in your pages/api routes: 48 | // import { mailgunClientGlobal } from '@/infra/mailgun'; 49 | // const mg = await mailgunClientGlobal 50 | // await mg.mailgun?.messages.create( 51 | // 'mail.mydomain.com', 52 | // {...mg.getDefaultValues(), 53 | // from: 'Excited User ', 54 | // to: ['contact@mydomain.com'] } 55 | // ); -------------------------------------------------------------------------------- /infra/prisma.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client" 2 | 3 | // To improve performance using Prisma ORM, we can set up the Prisma instance to ensure 4 | // only one instance is created throughout the project and then import it from any 5 | // file as needed. This approach avoids recreating instances of PrismaClient every time 6 | // it is used. Finally, we can import the Prisma instance from the auth.ts file configuration. 7 | const globalForPrisma = globalThis as unknown as { prisma: PrismaClient } 8 | 9 | export const prismaClientGlobal = globalForPrisma.prisma || new PrismaClient() 10 | 11 | if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prismaClientGlobal -------------------------------------------------------------------------------- /infra/providerDetector.ts: -------------------------------------------------------------------------------- 1 | // this file is a provider detector that uses the globalThis object to store the different providers that are availble 2 | // based on the environment file .env 3 | // everytime the app starts, it creates an object providersList that stores the different providers that are available 4 | // note that some are mandatory, they are marked optional: false 5 | 6 | export const providersList = { 7 | prisma: { 8 | name: "Prisma", 9 | isAvailable: true, 10 | optional: false, 11 | }, 12 | googleAuth: { 13 | name: "Google Auth", 14 | isAvailable: !!process.env.AUTH_GOOGLE_ID && process.env.AUTH_GOOGLE_ID !== '', 15 | optional: false, 16 | }, 17 | googleAnalytics: { 18 | name: "Google Analytics", 19 | isAvailable: process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID !== undefined, 20 | optional: true, 21 | id: process.env.NEXT_PUBLIC_GOOGLE_ANALYTICS_ID || '', 22 | }, 23 | googleTagManager: { 24 | name: "Google Tag Manager", 25 | isAvailable: process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID !== undefined, 26 | id : process.env.NEXT_PUBLIC_GOOGLE_TAG_MANAGER_ID || '', 27 | optional: true, 28 | }, 29 | landingPage: { 30 | name: "Landing Page", 31 | isAvailable: true, 32 | optional: false, 33 | }, 34 | stripe: { 35 | name: "Stripe", 36 | isAvailable: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY !== undefined, 37 | optional: true, 38 | }, 39 | mailgun: { 40 | name: "Mailgun", 41 | isAvailable: process.env.MAILGUN_API_KEY !== undefined, 42 | optional: true, 43 | }, 44 | } -------------------------------------------------------------------------------- /infra/stripe.ts: -------------------------------------------------------------------------------- 1 | import { loadStripe, Stripe } from '@stripe/stripe-js'; 2 | import { providersList } from './providerDetector'; 3 | 4 | class StripeWrapper { 5 | stripe: Stripe | null; 6 | constructor() { 7 | this.stripe = null; 8 | if (providersList.stripe.isAvailable) { 9 | this.initialize(); 10 | } 11 | } 12 | private async initialize(){ 13 | this.stripe = await loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY!); 14 | } 15 | public getStripe(){ 16 | return this.stripe; 17 | } 18 | } 19 | 20 | export const stripeInstance = new StripeWrapper(); 21 | -------------------------------------------------------------------------------- /middleware.ts: -------------------------------------------------------------------------------- 1 | import NextAuth from 'next-auth'; 2 | import { authConfig } from './auth.config'; 3 | 4 | // you must define a authorized callback in auth.config.ts 5 | // to use this middleware 6 | export default NextAuth(authConfig).auth; 7 | export const config = { 8 | // https://nextjs.org/docs/app/building-your-application/routing/middleware#matcher 9 | matcher: ['/((?!api|_next/static|_next/image|.*\\.png$|.*\\.svg$|.*\\.jpg$|.*\\.jpeg$|.*\\.ico$).*)'], 10 | }; -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const nextConfig = {}; 4 | 5 | export default nextConfig; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "//": "build : we need to add prisma generate due to vercel deployment", 5 | "build": "prisma generate && next build", 6 | "dev": "next dev", 7 | "lint": "next lint", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@auth/prisma-adapter": "^2.5.3", 12 | "@headlessui/react": "^2.1.8", 13 | "@heroicons/react": "^2.1.4", 14 | "@next/third-parties": "^14.2.14", 15 | "@prisma/client": "^5.20.0", 16 | "@stripe/stripe-js": "^4.6.0", 17 | "@tailwindcss/forms": "^0.5.7", 18 | "aos": "^2.3.4", 19 | "autoprefixer": "10.4.19", 20 | "axios": "^1.7.7", 21 | "bcrypt": "^5.1.1", 22 | "clsx": "^2.1.1", 23 | "form-data": "^4.0.1", 24 | "mailgun.js": "^10.2.3", 25 | "next": "14.2.14", 26 | "next-auth": "5.0.0-beta.22", 27 | "postcss": "8.4.38", 28 | "react": "19.0.0-rc-f38c22b244-20240704", 29 | "react-dom": "19.0.0-rc-f38c22b244-20240704", 30 | "stripe": "^17.1.0", 31 | "tailwindcss": "3.4.4", 32 | "use-debounce": "^10.0.1", 33 | "zod": "^3.23.8" 34 | }, 35 | "devDependencies": { 36 | "@types/bcrypt": "^5.0.2", 37 | "@types/node": "20.14.8", 38 | "@types/react": "18.3.3", 39 | "@types/react-dom": "18.3.0", 40 | "eslint": "^8", 41 | "eslint-config-next": "14.2.13", 42 | "prisma": "^5.20.0", 43 | "ts-node": "^10.9.2", 44 | "typescript": "5.5.2" 45 | }, 46 | "engines": { 47 | "node": ">=20.12.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /prisma/README.md: -------------------------------------------------------------------------------- 1 | # Prisma shortcuts 2 | 3 | If you are new to Primsa, here are a few commands to get you started. 4 | 5 | ### Invoke Prisma 6 | 7 | No need to install it globally on your machine. Just use `npx` to invoke it. For instance : 8 | 9 | ```bash 10 | npx prisma version 11 | ``` 12 | 13 | All commands are available in the [Prisma documentation](https://www.prisma.io/docs/orm/reference/prisma-cli-reference). 14 | 15 | ### Schema / Models 16 | 17 | - At first, we want to run `npx prisma migrate dev --name init` to creates SQL migration & run this migration file. It will init your Postgres database. You may sometimes need to run `npx prisma generate` for the prisma client. 18 | 19 | - We already created basics in the directory called `prisma`. Check out the file called `schema.prisma`, which contains your models. This is where you can define your database tables, and update it. 20 | 21 | #### Update the schema / models / Postgres Tables 22 | 23 | If you happen to update the schema, you can run the following command to update the database tables: 24 | 25 | ```bash 26 | npx prisma migrate dev 27 | ``` 28 | -------------------------------------------------------------------------------- /prisma/migrations/20240926140356_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL, 4 | "name" TEXT NOT NULL, 5 | "email" TEXT NOT NULL, 6 | "password" TEXT NOT NULL, 7 | 8 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 9 | ); 10 | 11 | -- CreateTable 12 | CREATE TABLE "Customer" ( 13 | "id" TEXT NOT NULL, 14 | "name" TEXT NOT NULL, 15 | "email" TEXT, 16 | "imageUrl" TEXT, 17 | 18 | CONSTRAINT "Customer_pkey" PRIMARY KEY ("id") 19 | ); 20 | -------------------------------------------------------------------------------- /prisma/migrations/20240927081526_user_updated_at/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "User" ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; 3 | -------------------------------------------------------------------------------- /prisma/migrations/20241002105605_database_adapter/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `password` on the `User` table. All the data in the column will be lost. 5 | - A unique constraint covering the columns `[email]` on the table `User` will be added. If there are existing duplicate values, this will fail. 6 | 7 | */ 8 | -- AlterTable 9 | ALTER TABLE "User" DROP COLUMN "password", 10 | ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 11 | ADD COLUMN "emailVerified" TIMESTAMP(3), 12 | ADD COLUMN "image" TEXT, 13 | ALTER COLUMN "name" DROP NOT NULL; 14 | 15 | -- CreateTable 16 | CREATE TABLE "Account" ( 17 | "userId" TEXT NOT NULL, 18 | "type" TEXT NOT NULL, 19 | "provider" TEXT NOT NULL, 20 | "providerAccountId" TEXT NOT NULL, 21 | "refresh_token" TEXT, 22 | "access_token" TEXT, 23 | "expires_at" INTEGER, 24 | "token_type" TEXT, 25 | "scope" TEXT, 26 | "id_token" TEXT, 27 | "session_state" TEXT, 28 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 29 | "updatedAt" TIMESTAMP(3) NOT NULL, 30 | 31 | CONSTRAINT "Account_pkey" PRIMARY KEY ("provider","providerAccountId") 32 | ); 33 | 34 | -- CreateTable 35 | CREATE TABLE "Session" ( 36 | "sessionToken" TEXT NOT NULL, 37 | "userId" TEXT NOT NULL, 38 | "expires" TIMESTAMP(3) NOT NULL, 39 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 40 | "updatedAt" TIMESTAMP(3) NOT NULL 41 | ); 42 | 43 | -- CreateTable 44 | CREATE TABLE "VerificationToken" ( 45 | "identifier" TEXT NOT NULL, 46 | "token" TEXT NOT NULL, 47 | "expires" TIMESTAMP(3) NOT NULL, 48 | 49 | CONSTRAINT "VerificationToken_pkey" PRIMARY KEY ("identifier","token") 50 | ); 51 | 52 | -- CreateIndex 53 | CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken"); 54 | 55 | -- CreateIndex 56 | CREATE UNIQUE INDEX "User_email_key" ON "User"("email"); 57 | 58 | -- AddForeignKey 59 | ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 60 | 61 | -- AddForeignKey 62 | ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; 63 | -------------------------------------------------------------------------------- /prisma/migrations/20241002171805_stripe/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `companyId` to the `User` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | 8 | -- CreateTable 9 | CREATE TABLE "Company" ( 10 | "id" TEXT NOT NULL, 11 | "name" TEXT NOT NULL, 12 | "stripeId" TEXT, 13 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 14 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 15 | 16 | CONSTRAINT "Company_pkey" PRIMARY KEY ("id") 17 | ); 18 | 19 | -- Insert a default company assigned to all users preexisting in the database 20 | INSERT INTO "Company" ("id", "name") VALUES ('default-company-id', 'Default Company'); 21 | 22 | -- AlterTable 23 | ALTER TABLE "User" ADD COLUMN "companyId" TEXT NOT NULL; 24 | 25 | -- AddForeignKey 26 | ALTER TABLE "User" ADD CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 27 | -------------------------------------------------------------------------------- /prisma/migrations/20241007153619_company/migration.sql: -------------------------------------------------------------------------------- 1 | -- DropForeignKey 2 | ALTER TABLE "User" DROP CONSTRAINT "User_companyId_fkey"; 3 | 4 | -- AlterTable 5 | ALTER TABLE "User" ALTER COLUMN "companyId" DROP NOT NULL; 6 | 7 | -- AddForeignKey 8 | ALTER TABLE "User" ADD CONSTRAINT "User_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE SET NULL ON UPDATE CASCADE; 9 | -------------------------------------------------------------------------------- /prisma/migrations/20241008120723_transaction/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `stripeId` on the `Company` table. All the data in the column will be lost. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "Company" DROP COLUMN "stripeId"; 9 | 10 | -- CreateTable 11 | CREATE TABLE "PaymentTransaction" ( 12 | "id" TEXT NOT NULL, 13 | "companyId" TEXT NOT NULL, 14 | "raw" JSONB NOT NULL, 15 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 16 | "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 17 | 18 | CONSTRAINT "PaymentTransaction_pkey" PRIMARY KEY ("id") 19 | ); 20 | 21 | -- AddForeignKey 22 | ALTER TABLE "PaymentTransaction" ADD CONSTRAINT "PaymentTransaction_companyId_fkey" FOREIGN KEY ("companyId") REFERENCES "Company"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 23 | -------------------------------------------------------------------------------- /prisma/migrations/20241008223030_customerremoval/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the `Customer` table. If the table is not empty, all the data it contains will be lost. 5 | 6 | */ 7 | -- DropTable 8 | DROP TABLE "Customer"; 9 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | generator client { 4 | provider = "prisma-client-js" 5 | } 6 | 7 | datasource db { 8 | provider = "postgresql" 9 | url = env("DATABASE_URL") 10 | } 11 | 12 | model User { 13 | id String @id @default(cuid()) 14 | name String? 15 | email String @unique 16 | emailVerified DateTime? 17 | image String? 18 | accounts Account[] 19 | sessions Session[] 20 | company Company? @relation(fields: [companyId], references: [id]) 21 | companyId String? 22 | createdAt DateTime @default(now()) 23 | updatedAt DateTime @updatedAt @default(now()) 24 | } 25 | 26 | model Company { 27 | id String @id @default(cuid()) 28 | name String 29 | users User[] @relation 30 | transactions PaymentTransaction[] 31 | createdAt DateTime @default(now()) 32 | updatedAt DateTime @updatedAt @default(now()) 33 | } 34 | model Account { 35 | userId String 36 | type String 37 | provider String 38 | providerAccountId String 39 | refresh_token String? 40 | access_token String? 41 | expires_at Int? 42 | token_type String? 43 | scope String? 44 | id_token String? 45 | session_state String? 46 | 47 | createdAt DateTime @default(now()) 48 | updatedAt DateTime @updatedAt 49 | 50 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 51 | 52 | @@id([provider, providerAccountId]) 53 | } 54 | 55 | model Session { 56 | sessionToken String @unique 57 | userId String 58 | expires DateTime 59 | user User @relation(fields: [userId], references: [id], onDelete: Cascade) 60 | 61 | createdAt DateTime @default(now()) 62 | updatedAt DateTime @updatedAt 63 | } 64 | 65 | model VerificationToken { 66 | identifier String 67 | token String 68 | expires DateTime 69 | 70 | @@id([identifier, token]) 71 | } 72 | 73 | model PaymentTransaction { 74 | id String @id @default(cuid()) 75 | company Company @relation(fields: [companyId], references: [id]) 76 | companyId String 77 | raw Json 78 | createdAt DateTime @default(now()) 79 | updatedAt DateTime @updatedAt @default(now()) 80 | } -------------------------------------------------------------------------------- /public/billing-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/billing-screenshot.png -------------------------------------------------------------------------------- /public/customers/amy-burns.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/customers/amy-burns.png -------------------------------------------------------------------------------- /public/customers/balazs-orban.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/customers/balazs-orban.png -------------------------------------------------------------------------------- /public/customers/delba-de-oliveira.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/customers/delba-de-oliveira.png -------------------------------------------------------------------------------- /public/customers/evil-rabbit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/customers/evil-rabbit.png -------------------------------------------------------------------------------- /public/customers/lee-robinson.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/customers/lee-robinson.png -------------------------------------------------------------------------------- /public/customers/michael-novotny.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/customers/michael-novotny.png -------------------------------------------------------------------------------- /public/google-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Google-color 6 | Created with Sketch. 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /public/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/hero-desktop.png -------------------------------------------------------------------------------- /public/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/hero-mobile.png -------------------------------------------------------------------------------- /public/landing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/landing.png -------------------------------------------------------------------------------- /public/landingFeatures.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/landingFeatures.png -------------------------------------------------------------------------------- /public/sso.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guillim/nextjs-boilerplate/26befb8dd18fd3dd7210c490319a50c30286625a/public/sso.png -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | const config: Config = { 4 | content: [ 5 | './pages/**/*.{js,ts,jsx,tsx,mdx}', 6 | './components/**/*.{js,ts,jsx,tsx,mdx}', 7 | './app/**/*.{js,ts,jsx,tsx,mdx}', 8 | ], 9 | theme: { 10 | extend: { 11 | gridTemplateColumns: { 12 | '13': 'repeat(13, minmax(0, 1fr))', 13 | }, 14 | colors: { 15 | blue: { 16 | 400: '#2589FE', 17 | 500: '#0070F3', 18 | 600: '#2F6FEB', 19 | }, 20 | }, 21 | fontSize: { 22 | xs: '0.75rem', 23 | sm: '0.875rem', 24 | base: '1rem', 25 | lg: '1.125rem', 26 | xl: '1.25rem', 27 | '2xl': '1.5rem', 28 | '3xl': '2rem', 29 | '4xl': '2.5rem', 30 | '5xl': '3.25rem', 31 | '6xl': '4rem', 32 | }, 33 | letterSpacing: { 34 | tighter: '-0.02em', 35 | tight: '-0.01em', 36 | normal: '0', 37 | wide: '0.01em', 38 | wider: '0.02em', 39 | widest: '0.4em', 40 | }, 41 | }, 42 | keyframes: { 43 | shimmer: { 44 | '100%': { 45 | transform: 'translateX(100%)', 46 | }, 47 | }, 48 | }, 49 | }, 50 | plugins: [require('@tailwindcss/forms')], 51 | }; 52 | export default config; 53 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2017", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "baseUrl": ".", 22 | "paths": { 23 | "@/*": ["./*"] 24 | } 25 | }, 26 | "include": [ 27 | "next-env.d.ts", 28 | "**/*.ts", 29 | "**/*.tsx", 30 | ".next/types/**/*.ts", 31 | "app/lib/placeholder-data.ts", 32 | "scripts/seed.js" 33 | ], 34 | "exclude": ["node_modules"] 35 | } 36 | --------------------------------------------------------------------------------