├── .env.example ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── README.md ├── components.json ├── index.d.ts ├── next.config.mjs ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── images │ ├── brix │ │ ├── Arrow 1.svg │ │ ├── Arrow 2.svg │ │ ├── Arrow 6.svg │ │ ├── Circle 4.svg │ │ ├── Circle 5.svg │ │ ├── Circle 7.svg │ │ ├── Line 1.svg │ │ ├── Line 3.svg │ │ ├── Line 6.svg │ │ └── Line 7.svg │ ├── design-laptop.png │ ├── docs │ │ ├── billing-active-subscription.png │ │ ├── billing-change-plans.png │ │ └── billing-plans.png │ └── placeholder.svg ├── next.svg └── vercel.svg ├── sentry.client.config.ts ├── sentry.edge.config.ts ├── sentry.server.config.ts ├── shims.d.ts ├── src ├── app │ ├── [locale] │ │ ├── (dashboard) │ │ │ ├── components │ │ │ │ ├── DashboardPageView.tsx │ │ │ │ ├── NewTodoItemButton.tsx │ │ │ │ ├── NewTodoItemCard.tsx │ │ │ │ ├── RecentTodoItemsCard.tsx │ │ │ │ ├── TodoItemSheet.tsx │ │ │ │ ├── TodoItemsTable.tsx │ │ │ │ ├── UpcomingTodoItemsCard.tsx │ │ │ │ ├── charts │ │ │ │ │ ├── BarChartCard.tsx │ │ │ │ │ └── LineChartCard.tsx │ │ │ │ └── todo-item-form-sheet │ │ │ │ │ ├── TodoItemForm.tsx │ │ │ │ │ ├── TodoItemFormActions.tsx │ │ │ │ │ └── TodoItemFormSheet.tsx │ │ │ ├── dashboard │ │ │ │ ├── my-account │ │ │ │ │ └── page.tsx │ │ │ │ ├── page.tsx │ │ │ │ ├── settings │ │ │ │ │ ├── components │ │ │ │ │ │ ├── GeneralSettingsView.tsx │ │ │ │ │ │ └── SettingsPageView.tsx │ │ │ │ │ └── page.tsx │ │ │ │ └── todo │ │ │ │ │ ├── components │ │ │ │ │ └── TodoPageView.tsx │ │ │ │ │ └── page.tsx │ │ │ └── layout.tsx │ │ ├── [...rest] │ │ │ └── page.tsx │ │ ├── error.tsx │ │ ├── layout.tsx │ │ ├── not-found.tsx │ │ └── page.tsx │ ├── api │ │ ├── admin │ │ │ ├── resend │ │ │ │ └── route.ts │ │ │ └── todo │ │ │ │ └── boilerplate │ │ │ │ ├── boilerplate.utils.ts │ │ │ │ └── route.ts │ │ ├── todo │ │ │ ├── [id] │ │ │ │ └── route.ts │ │ │ ├── new │ │ │ │ └── route.ts │ │ │ └── route.ts │ │ ├── user │ │ │ └── route.ts │ │ └── webhooks │ │ │ ├── clerk │ │ │ └── route.ts │ │ │ └── lemonsqueezy │ │ │ └── route.ts │ ├── global-error.jsx │ ├── layout.tsx │ ├── loading.tsx │ └── not-found.jsx ├── globals.css ├── libs │ ├── admin │ │ ├── index.ts │ │ └── utils.ts │ ├── components │ │ ├── DarkModeToggle.tsx │ │ ├── DeleteElementWithAlertDialog.tsx │ │ ├── ElementsTable.tsx │ │ ├── GlobalCurrencySelector.tsx │ │ ├── Loading.tsx │ │ ├── LocaleSelector.tsx │ │ ├── dashboard │ │ │ ├── DashboardHeader.tsx │ │ │ ├── DashboardSidebar.tsx │ │ │ ├── DashboardStatsCard.tsx │ │ │ ├── SubscribeButton.tsx │ │ │ ├── index.ts │ │ │ └── useSidenavRoutes.ts │ │ ├── form │ │ │ ├── FormInput.tsx │ │ │ ├── FormInputDate.tsx │ │ │ ├── FormInputSwitch.tsx │ │ │ ├── FormSelect.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── landing-page │ │ │ ├── LandingPageCTA.tsx │ │ │ ├── LandingPageFAQ.tsx │ │ │ ├── LandingPageFeatures.tsx │ │ │ ├── LandingPageFooter.tsx │ │ │ ├── LandingPageHeader.tsx │ │ │ ├── LandingPageHero.tsx │ │ │ ├── LandingPagePricing.tsx │ │ │ ├── LandingPageWaitlist.tsx │ │ │ └── index.ts │ │ └── lemon-squeezy │ │ │ ├── ChangePlans.tsx │ │ │ ├── ChangePlansButton.tsx │ │ │ ├── CheckoutButton.tsx │ │ │ ├── Plan.tsx │ │ │ ├── Subscriptions.tsx │ │ │ ├── SubscriptionsActions.tsx │ │ │ └── SubscriptionsActionsDropdown.tsx │ ├── cookies │ │ └── currency │ │ │ ├── getDisplayCurrency.ts │ │ │ └── useDisplayCurrency.ts │ ├── database │ │ ├── functions │ │ │ ├── todo │ │ │ │ ├── createTodoItem.ts │ │ │ │ ├── deleteTodoItem.ts │ │ │ │ ├── getTodoItems.ts │ │ │ │ ├── getUserRecentTodoItems.ts │ │ │ │ ├── getUserUpcomingTodoItems.ts │ │ │ │ ├── index.ts │ │ │ │ └── updateTodoItem.ts │ │ │ └── user │ │ │ │ ├── index.ts │ │ │ │ └── updateUser.ts │ │ ├── index.ts │ │ ├── migrations │ │ │ ├── 20240430143651_ │ │ │ │ └── migration.sql │ │ │ └── migration_lock.toml │ │ ├── prisma-client.ts │ │ ├── schema.prisma │ │ ├── types.ts │ │ └── utils.ts │ ├── lemon-squeezy │ │ ├── actions.ts │ │ ├── config.ts │ │ ├── typeguards.ts │ │ └── utils.ts │ ├── locales │ │ ├── client.ts │ │ ├── en.ts │ │ ├── es.ts │ │ ├── locale-middleware.ts │ │ └── server.ts │ ├── providers │ │ ├── google-analytics.tsx │ │ ├── index.ts │ │ ├── lemon-squeezy.tsx │ │ ├── locale-provider.tsx │ │ ├── signedin-user-provider.tsx │ │ ├── theme-provider.tsx │ │ ├── toast-provider.tsx │ │ └── tooltip-provider.tsx │ ├── resend │ │ ├── resend-client.ts │ │ └── sendEmail.ts │ ├── ui │ │ ├── accordion.tsx │ │ ├── alert-dialog.tsx │ │ ├── alert.tsx │ │ ├── avatar.tsx │ │ ├── badge.tsx │ │ ├── breadcrumb.tsx │ │ ├── button.tsx │ │ ├── calendar.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── date-picker.tsx │ │ ├── dropdown-menu.tsx │ │ ├── form.tsx │ │ ├── input.tsx │ │ ├── label.tsx │ │ ├── pagination.tsx │ │ ├── popover.tsx │ │ ├── progress.tsx │ │ ├── select.tsx │ │ ├── separator.tsx │ │ ├── sheet.tsx │ │ ├── skeleton.tsx │ │ ├── switch.tsx │ │ ├── table.tsx │ │ ├── tabs.tsx │ │ ├── textarea.tsx │ │ ├── toast.tsx │ │ ├── toaster.tsx │ │ ├── toggle-group.tsx │ │ ├── toggle.tsx │ │ ├── tooltip.tsx │ │ └── use-toast.ts │ └── utils │ │ ├── cn.ts │ │ ├── fetcher.ts │ │ ├── format.ts │ │ ├── index.ts │ │ └── math.ts ├── middleware.ts └── pages │ ├── _app.tsx │ ├── _document.tsx │ ├── _meta.json │ └── docs │ ├── _meta.json │ ├── dashboard.mdx │ ├── deployment.mdx │ ├── features.mdx │ ├── features │ ├── _meta.json │ ├── authentication.mdx │ ├── cookies.mdx │ ├── database │ │ ├── _meta.json │ │ ├── prisma.mdx │ │ └── supabase.mdx │ ├── emails.mdx │ ├── errors.mdx │ ├── google-analytics.mdx │ ├── i18n.mdx │ ├── lemon-squeezy.mdx │ ├── mdx-documentation.mdx │ ├── nextjs.mdx │ └── ui-themes.mdx │ ├── get-started.mdx │ ├── index.mdx │ ├── landing-page.mdx │ ├── upcoming-features.mdx │ └── upcoming-features │ ├── _meta.json │ └── openai.mdx ├── tailwind.config.js ├── theme.config.jsx └── tsconfig.json /.env.example: -------------------------------------------------------------------------------- 1 | # Supabase connection - picked up by Prisma 2 | SUPABASE_DATABASE_URL= # Set this to the Transaction connection pooler string 3 | SUPABASE_DIRECT_URL= # Set this to the Session connection pooler string 4 | 5 | # Used to check if the signed in user is the author of this project (for admin features) 6 | NEXT_PUBLIC_AUTHOR_EMAIL= 7 | 8 | # Clerk 9 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY= 10 | CLERK_SECRET_KEY= 11 | 12 | # Sentry DSN 13 | SENTRY_DSN= 14 | SENTRY_ORG= 15 | SENTRY_PROJECT= 16 | 17 | # Resend 18 | RESEND_API_KEY= 19 | 20 | # Google Analytics 21 | NEXT_PUBLIC_MEASUREMENT_ID= 22 | 23 | # Lemon Squeezy 24 | LEMONSQUEEZY_API_KEY= 25 | LEMONSQUEEZY_STORE_ID= 26 | LEMONSQUEEZY_WEBHOOK_SECRET= -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next", "next/core-web-vitals"], 3 | "ignorePatterns": ["!**/*", ".next/**/*"], 4 | "overrides": [ 5 | { 6 | "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], 7 | "rules": { 8 | "@next/next/no-html-link-for-pages": ["error"], 9 | "@next/next/no-img-element": "off" 10 | } 11 | }, 12 | { 13 | "files": ["*.ts", "*.tsx"], 14 | "rules": {} 15 | }, 16 | { 17 | "files": ["*.js", "*.jsx"], 18 | "rules": {} 19 | }, 20 | { 21 | "files": ["*.spec.ts", "*.spec.tsx", "*.spec.js", "*.spec.jsx"], 22 | "env": { 23 | "jest": true 24 | } 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /.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 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | .env*.production 31 | .env 32 | 33 | # vercel 34 | .vercel 35 | 36 | # typescript 37 | *.tsbuildinfo 38 | next-env.d.ts 39 | 40 | # Sentry Config File 41 | .sentryclirc 42 | 43 | # Sentry Config File 44 | .sentryclirc 45 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 4, 4 | "trailingComma": "all", 5 | "singleQuote": true, 6 | "semi": true, 7 | "importOrder": ["^@core/(.*)$", "^@server/(.*)$", "^@ui/(.*)$", "^[./]"], 8 | "importOrderSeparation": true, 9 | "importOrderSortSpecifiers": true 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SaasterKit: The Next.js Boilerplate Kit for SaaS Apps 2 | 3 | Welcome to SaasterKit, a comprehensive solution designed to streamline the development process and accelerate the creation of modern web applications. This Next.js Boilerplate Kit aims to address the common pain point of spending an excessive amount of time on boilerplate code setup by providing a solid foundation with essential features pre-configured, allowing you to **focus on implementing the core business logic quickly and efficiently.** 4 | 5 | ### Features 6 | 7 | The following features are available out-of-the-box and ready to use in this Next.js Boilerplate Kit. The project uses **Next.js 14 app router** for efficient routing, **Prisma ORM**, **Supabase** and **PostgreSQL** for database management, **Clerk** for authentication, **Tailwind CSS**, **Shadcn** and **Radix** for UI components, **dark/light** themes, **next-international** for i18n multi-language support, **Resend** for email support, **Sentry** for error reporting, and **Lemon Squeezy** integration for streamlined payment processing. 8 | 9 | ### Upcoming Features 10 | 11 | This Boilerplate Kit is planned to be extended further to include advanced features such as **MDX documentation** integration, **OpenAI** integration for artificial intelligence capabilities. These upcoming features aim to enhance the overall functionality and user experience of the boilerplate kit, providing you with access to cutting-edge technologies and tools to elevate your projects to the next level. 12 | 13 | Whether you are starting a new project or looking to accelerate the development of an existing application, this Next.js Boilerplate Kit serves as a solid foundation to kickstart your development journey and unlock the full potential of your web applications with easily. 14 | 15 | ## Full documentation 16 | 17 | Find the full documentation [here](https://saasterkit.vercel.app/docs) 18 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "src/globals.css", 9 | "baseColor": "stone", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/libs/ui", 15 | "ui": "@/libs/ui", 16 | "utils": "@/libs/utils" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const content: any; 3 | export const ReactComponent: any; 4 | export default content; 5 | } 6 | -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | import { withSentryConfig } from '@sentry/nextjs'; 2 | import nextra from 'nextra'; 3 | 4 | /** @type {import('next').NextConfig} */ 5 | let nextConfig = {}; 6 | 7 | const withNextra = nextra({ 8 | theme: 'nextra-theme-docs', 9 | themeConfig: './theme.config.jsx', 10 | }); 11 | 12 | // Wrap with Nextra for documentation support 13 | nextConfig = withNextra(nextConfig); 14 | 15 | // Check if the environment variables for Sentry are set. If they are, enable Sentry. 16 | const sentryEnabled = 17 | process.env.SENTRY_ORG && process.env.SENTRY_PROJECT && process.env.SENTRY_DSN; 18 | if (sentryEnabled) { 19 | nextConfig = withSentryConfig( 20 | nextConfig, 21 | { 22 | // For all available options, see: 23 | // https://github.com/getsentry/sentry-webpack-plugin#options 24 | 25 | // Suppresses source map uploading logs during build 26 | silent: true, 27 | org: `${process.env.SENTRY_ORG}`, 28 | project: `${process.env.SENTRY_PROJECT}`, 29 | }, 30 | { 31 | // For all available options, see: 32 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/ 33 | 34 | // Upload a larger set of source maps for prettier stack traces (increases build time) 35 | widenClientFileUpload: true, 36 | 37 | // Transpiles SDK to be compatible with IE11 (increases bundle size) 38 | transpileClientSDK: true, 39 | 40 | // Uncomment to route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers. 41 | // This can increase your server load as well as your hosting bill. 42 | // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client- 43 | // side errors will fail. 44 | // tunnelRoute: "/monitoring", 45 | 46 | // Hides source maps from generated client bundles 47 | hideSourceMaps: true, 48 | 49 | // Automatically tree-shake Sentry logger statements to reduce bundle size 50 | disableLogger: true, 51 | 52 | // Enables automatic instrumentation of Vercel Cron Monitors. 53 | // See the following for more information: 54 | // https://docs.sentry.io/product/crons/ 55 | // https://vercel.com/docs/cron-jobs 56 | automaticVercelMonitors: true, 57 | }, 58 | ); 59 | } 60 | 61 | export default nextConfig; 62 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leandroercoli/SaasterKit/30edbd568dc173c62bd906d5e940527585993238/public/favicon.ico -------------------------------------------------------------------------------- /public/images/brix/Arrow 1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/images/brix/Arrow 2.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/images/brix/Arrow 6.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/images/brix/Circle 4.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/brix/Circle 7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/brix/Line 1.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/brix/Line 3.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/images/brix/Line 6.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /public/images/brix/Line 7.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /public/images/design-laptop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leandroercoli/SaasterKit/30edbd568dc173c62bd906d5e940527585993238/public/images/design-laptop.png -------------------------------------------------------------------------------- /public/images/docs/billing-active-subscription.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leandroercoli/SaasterKit/30edbd568dc173c62bd906d5e940527585993238/public/images/docs/billing-active-subscription.png -------------------------------------------------------------------------------- /public/images/docs/billing-change-plans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leandroercoli/SaasterKit/30edbd568dc173c62bd906d5e940527585993238/public/images/docs/billing-change-plans.png -------------------------------------------------------------------------------- /public/images/docs/billing-plans.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leandroercoli/SaasterKit/30edbd568dc173c62bd906d5e940527585993238/public/images/docs/billing-plans.png -------------------------------------------------------------------------------- /public/images/placeholder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/next.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/vercel.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /sentry.client.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the client. 2 | // The config you add here will be used whenever a users loads a page in their browser. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | import * as Sentry from '@sentry/nextjs'; 5 | 6 | Sentry.init({ 7 | dsn: `${process.env.SENTRY_DSN}`, 8 | 9 | // Adjust this value in production, or use tracesSampler for greater control 10 | tracesSampleRate: 1, 11 | 12 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 13 | debug: false, 14 | 15 | replaysOnErrorSampleRate: 1.0, 16 | 17 | // This sets the sample rate to be 10%. You may want this to be 100% while 18 | // in development and sample at a lower rate in production 19 | replaysSessionSampleRate: 0.1, 20 | 21 | // You can remove this option if you're not planning to use the Sentry Session Replay feature: 22 | integrations: [ 23 | Sentry.replayIntegration({ 24 | // Additional Replay configuration goes in here, for example: 25 | maskAllText: true, 26 | blockAllMedia: true, 27 | }), 28 | ], 29 | }); 30 | -------------------------------------------------------------------------------- /sentry.edge.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry for edge features (middleware, edge routes, and so on). 2 | // The config you add here will be used whenever one of the edge features is loaded. 3 | // Note that this config is unrelated to the Vercel Edge Runtime and is also required when running locally. 4 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | Sentry.init({ 8 | dsn: `${process.env.SENTRY_DSN}`, 9 | 10 | // Adjust this value in production, or use tracesSampler for greater control 11 | tracesSampleRate: 1, 12 | 13 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 14 | debug: false, 15 | }); 16 | -------------------------------------------------------------------------------- /sentry.server.config.ts: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | import * as Sentry from '@sentry/nextjs'; 5 | 6 | Sentry.init({ 7 | dsn: `${process.env.SENTRY_DSN}`, 8 | 9 | // Adjust this value in production, or use tracesSampler for greater control 10 | tracesSampleRate: 1, 11 | 12 | // Setting this option to true will print useful information to the console while you're setting up Sentry. 13 | debug: false, 14 | 15 | // uncomment the line below to enable Spotlight (https://spotlightjs.com) 16 | // spotlight: process.env.NODE_ENV === 'development', 17 | }); 18 | -------------------------------------------------------------------------------- /shims.d.ts: -------------------------------------------------------------------------------- 1 | interface Window { 2 | createLemonSqueezy: () => void; 3 | LemonSqueezy: { 4 | /** 5 | * Initialises Lemon.js on your page. 6 | * @param options - An object with a single property, eventHandler, which is a function that will be called when Lemon.js emits an event. 7 | */ 8 | Setup: (options: { 9 | eventHandler: (event: { event: string }) => void; 10 | }) => void; 11 | /** 12 | * Refreshes `lemonsqueezy-button` listeners on the page. 13 | */ 14 | Refresh: () => void; 15 | 16 | Url: { 17 | /** 18 | * Opens a given Lemon Squeezy URL, typically these are Checkout or Payment Details Update overlays. 19 | * @param url - The URL to open. 20 | */ 21 | Open: (url: string) => void; 22 | 23 | /** 24 | * Closes the current opened Lemon Squeezy overlay checkout window. 25 | */ 26 | Close: () => void; 27 | }; 28 | Affiliate: { 29 | /** 30 | * Retrieve the affiliate tracking ID 31 | */ 32 | GetID: () => string; 33 | 34 | /** 35 | * Append the affiliate tracking parameter to the given URL 36 | * @param url - The URL to append the affiliate tracking parameter to. 37 | */ 38 | Build: (url: string) => string; 39 | }; 40 | }; 41 | } 42 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/components/DashboardPageView.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { DashboardStatsCard } from '@/libs/components'; 4 | import { useDisplayCurrency } from '@/libs/cookies/currency/useDisplayCurrency'; 5 | import { useI18n } from '@/libs/locales/client'; 6 | import { formatDateShortDay } from '@/libs/utils'; 7 | import { TodoItem } from '@prisma/client'; 8 | import moment from 'moment'; 9 | 10 | import { NewTodoItemCard } from './NewTodoItemCard'; 11 | import { RecentTodoItemsCard } from './RecentTodoItemsCard'; 12 | import { UpcomingTodoItemsCard } from './UpcomingTodoItemsCard'; 13 | import { BarChartCard } from './charts/BarChartCard'; 14 | 15 | const startOfMonth = moment() 16 | .startOf('month') 17 | .startOf('day') 18 | .format('YYYY-MM-DD'); 19 | const endOfMonth = moment().endOf('month').endOf('day').format('YYYY-MM-DD'); 20 | 21 | export function DashboardPageView({ 22 | upcomingTodoItems, 23 | recentTodoItems, 24 | }: { 25 | upcomingTodoItems: TodoItem[]; 26 | recentTodoItems: TodoItem[]; 27 | }) { 28 | const t = useI18n(); 29 | 30 | // Globally selected currency 31 | const { displayCurrency } = useDisplayCurrency(); 32 | 33 | return ( 34 | <> 35 |
36 | 37 | 46 | 56 | 57 |
58 |
59 | 60 | 61 |
62 | 63 | ); 64 | } 65 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/components/NewTodoItemButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import { useState } from 'react'; 6 | 7 | import { TodoItemFormSheet } from './todo-item-form-sheet/TodoItemFormSheet'; 8 | 9 | // CTA button to create a new todo item on a sheet component 10 | export function NewTodoItemButton() { 11 | const t = useI18n(); 12 | 13 | // New bill state for the sheet 14 | const [newTodoItemOpen, setNewTodoItemOpen] = useState(false); 15 | 16 | return ( 17 | <> 18 | {newTodoItemOpen && ( 19 | { 21 | setNewTodoItemOpen(false); 22 | }} 23 | /> 24 | )} 25 | 34 | 35 | ); 36 | } 37 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/components/NewTodoItemCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { 5 | Card, 6 | CardDescription, 7 | CardFooter, 8 | CardHeader, 9 | CardTitle, 10 | } from '@/libs/ui/card'; 11 | 12 | import { NewTodoItemButton } from './NewTodoItemButton'; 13 | 14 | export function NewTodoItemCard() { 15 | const t = useI18n(); 16 | 17 | return ( 18 | 19 | 20 | {t('dashboard.todo.cta_card.title')} 21 | 22 | {t('dashboard.todo.cta_card.description')} 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/components/RecentTodoItemsCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import { 6 | Card, 7 | CardContent, 8 | CardDescription, 9 | CardHeader, 10 | CardTitle, 11 | } from '@/libs/ui/card'; 12 | import { TodoItem } from '@prisma/client'; 13 | import { ArrowUpRight } from 'lucide-react'; 14 | import { useRouter } from 'next/navigation'; 15 | 16 | import { TodoItemsTable } from './TodoItemsTable'; 17 | 18 | export function RecentTodoItemsCard({ 19 | recentTodoItems, 20 | }: { 21 | recentTodoItems: TodoItem[]; 22 | }) { 23 | const router = useRouter(); 24 | const t = useI18n(); 25 | 26 | return ( 27 | <> 28 | 29 | 30 |
31 | 32 | {t('dashboard.todo.recent_title')} 33 | 34 | 35 | {t('dashboard.todo.recent_description')} 36 | 37 |
38 |
39 | 50 |
51 |
52 | 53 | { 54 | // If there are no upcoming todo items 55 | !recentTodoItems.length ? ( 56 |

57 | {t('dashboard.todo.no_todo_items')} 58 |

59 | ) : ( 60 | 61 | ) 62 | } 63 |
64 |
65 | 66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/components/charts/BarChartCard.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@/libs/locales/client'; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from '@/libs/ui/card'; 9 | import { Bar, BarChart, ResponsiveContainer } from 'recharts'; 10 | 11 | const data = [ 12 | { 13 | date: '2022-01-01', 14 | total: 14, 15 | }, 16 | { 17 | date: '2022-02-01', 18 | total: 20, 19 | }, 20 | { 21 | date: '2022-03-01', 22 | total: 28, 23 | }, 24 | { 25 | date: '2022-04-01', 26 | total: 20, 27 | }, 28 | { 29 | date: '2022-05-01', 30 | total: 25, 31 | }, 32 | ]; 33 | 34 | export function BarChartCard() { 35 | const t = useI18n(); 36 | 37 | return ( 38 | 39 | 40 |
41 | {t('dashboard.todo.stats.total')} 42 |
43 | +5 44 |
45 | 46 |
47 | 48 | 57 | 66 | 67 | 68 |
69 |
70 |
71 | ); 72 | } 73 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/components/charts/LineChartCard.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@/libs/locales/client'; 2 | import { 3 | Card, 4 | CardContent, 5 | CardDescription, 6 | CardHeader, 7 | CardTitle, 8 | } from '@/libs/ui/card'; 9 | import { Line, LineChart, ResponsiveContainer } from 'recharts'; 10 | 11 | const data = [ 12 | { 13 | date: '2022-01-01', 14 | total: 10, 15 | }, 16 | { 17 | date: '2022-02-01', 18 | total: 20, 19 | }, 20 | { 21 | date: '2022-03-01', 22 | total: 22, 23 | }, 24 | { 25 | date: '2022-04-01', 26 | total: 28, 27 | }, 28 | { 29 | date: '2022-05-01', 30 | total: 40, 31 | }, 32 | ]; 33 | 34 | export function LineChartCard() { 35 | const t = useI18n(); 36 | 37 | return ( 38 | 39 | 40 |
41 | 42 | {t('dashboard.todo.stats.total')} 43 | 44 |
45 | +12% 46 |
47 | 48 |
49 | 50 | 59 | 76 | 77 | 78 |
79 |
80 |
81 | ); 82 | } 83 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/components/todo-item-form-sheet/TodoItemFormSheet.tsx: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@/libs/locales/client'; 2 | import { Badge } from '@/libs/ui/badge'; 3 | import { 4 | Sheet, 5 | SheetContent, 6 | SheetDescription, 7 | SheetHeader, 8 | SheetTitle, 9 | } from '@/libs/ui/sheet'; 10 | import { TodoItem } from '@prisma/client'; 11 | 12 | import { TodoItemForm } from './TodoItemForm'; 13 | 14 | // Sheet to create or edit a new todo item 15 | export function TodoItemFormSheet({ 16 | todoItem, 17 | onClose, 18 | }: { 19 | todoItem?: TodoItem | null; 20 | onClose: () => void; 21 | }) { 22 | const t = useI18n(); 23 | 24 | return ( 25 | { 28 | if (!isOpen) { 29 | onClose(); 30 | } 31 | }} 32 | > 33 | 34 | 35 | 36 | {todoItem?.id 37 | ? t('dashboard.todo.form.edit') 38 | : t('dashboard.todo.form.new')} 39 | 40 | 41 | {todoItem?.id 42 | ? todoItem?.title 43 | : t('dashboard.todo.form.new_description')}{' '} 44 | {todoItem?.id ? ( 45 | 49 | {todoItem.done 50 | ? t('dashboard.todo.item.done') 51 | : t('dashboard.todo.item.pending')} 52 | 53 | ) : null} 54 | 55 | 56 |
57 | 58 |
59 |
60 |
61 | ); 62 | } 63 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/dashboard/my-account/page.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from '@/libs/components/dashboard'; 2 | import { Subscriptions } from '@/libs/components/lemon-squeezy/Subscriptions'; 3 | import { prisma } from '@/libs/database'; 4 | import { getUserSubscriptions, syncPlans } from '@/libs/lemon-squeezy/actions'; 5 | import { getI18n } from '@/libs/locales/server'; 6 | 7 | export default async function MyAccount() { 8 | const t = await getI18n(); 9 | 10 | const userSubscriptions = await getUserSubscriptions(); 11 | let allPlans = await prisma.lsSubscriptionPlan.findMany(); 12 | 13 | // If there are no plans in the database, sync them from Lemon Squeezy. 14 | // You might want to add logic to sync plans periodically or a webhook handler. 15 | if (!allPlans.length) { 16 | allPlans = await syncPlans(); 17 | } 18 | 19 | // Show active subscriptions first, then paused, then canceled 20 | const sortedSubscriptions = userSubscriptions.sort((a, b) => { 21 | if (a.status === 'active' && b.status !== 'active') { 22 | return -1; 23 | } 24 | 25 | if (a.status === 'paused' && b.status === 'cancelled') { 26 | return -1; 27 | } 28 | 29 | return 0; 30 | }); 31 | 32 | return ( 33 |
34 |
35 | 46 |
47 |
48 | 52 |
53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/dashboard/page.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from '@/libs/components/dashboard'; 2 | import { getUserRecentTodoItems } from '@/libs/database/functions/todo/getUserRecentTodoItems'; 3 | import { getUserUpcomingTodoItems } from '@/libs/database/functions/todo/getUserUpcomingTodoItems'; 4 | 5 | import { DashboardPageView } from '../components/DashboardPageView'; 6 | 7 | export default async function Dashboard() { 8 | const recentTodoItems = await getUserRecentTodoItems(); 9 | const upcomingTodoItems = await getUserUpcomingTodoItems(); 10 | 11 | return ( 12 |
13 |
14 | 15 |
16 |
17 | 21 |
22 |
23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/dashboard/settings/components/SettingsPageView.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import Link from 'next/link'; 5 | 6 | import { GeneralSettingsView } from './GeneralSettingsView'; 7 | 8 | export default function SettingsPageView() { 9 | const t = useI18n(); 10 | return ( 11 | <> 12 |
13 |

14 | {t('dashboard.settings.title')} 15 |

16 |
17 |
18 | 32 | 33 | 34 |
35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/dashboard/settings/page.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from '@/libs/components/dashboard'; 2 | import { getI18n } from '@/libs/locales/server'; 3 | 4 | import SettingsPageView from './components/SettingsPageView'; 5 | 6 | export default async function Settings() { 7 | const t = await getI18n(); 8 | 9 | return ( 10 |
11 |
12 | 23 |
24 |
25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/dashboard/todo/page.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardHeader } from '@/libs/components/dashboard'; 2 | import { getI18n } from '@/libs/locales/server'; 3 | 4 | import { TodoPageView } from './components/TodoPageView'; 5 | 6 | export default async function Dashboard() { 7 | const t = await getI18n(); 8 | 9 | return ( 10 |
11 |
12 | 23 |
24 |
25 | 26 |
27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/app/[locale]/(dashboard)/layout.tsx: -------------------------------------------------------------------------------- 1 | import { DashboardSidebar } from '@/libs/components/dashboard/DashboardSidebar'; 2 | import { getSignedInUser } from '@/libs/database'; 3 | import { SignedInUserProvider } from '@/libs/providers'; 4 | 5 | // Signed in user provider layout 6 | export default async function Layout({ 7 | children, 8 | }: Readonly<{ 9 | children: React.ReactNode; 10 | }>) { 11 | const signedInUser = await getSignedInUser(); 12 | 13 | return ( 14 | 15 |
16 | 19 |
{children}
20 |
21 |
22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/app/[locale]/[...rest]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from 'next/navigation'; 2 | 3 | // This page will be rendered when the user navigates to a page that doesn't exist. This is needed to correctly route to the custom NotFound page when using i18n routing. 4 | export default function CatchAllPage() { 5 | notFound(); 6 | } 7 | -------------------------------------------------------------------------------- /src/app/[locale]/error.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import * as Sentry from '@sentry/nextjs'; 6 | import Image from 'next/image'; 7 | import { useEffect } from 'react'; 8 | 9 | export default function Error({ 10 | error, 11 | }: { 12 | error: Error & { digest?: string }; 13 | }) { 14 | const t = useI18n(); 15 | 16 | useEffect(() => { 17 | // Log the error to an error reporting service, e.g. Sentry 18 | console.error('Error', error); 19 | Sentry?.captureException(error); 20 | }, [error]); 21 | 22 | return ( 23 |
24 |
25 |
26 |
27 |

28 | {t('error.title')} 29 |

30 |
31 |
32 | 35 |
36 |
37 |
38 |
39 | Image 46 |
47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/app/[locale]/layout.tsx: -------------------------------------------------------------------------------- 1 | import { LocaleProvider } from '@/libs/providers'; 2 | 3 | // Locale provider layout 4 | export default async function LocaleLayout({ 5 | children, 6 | params: { locale = 'en' }, 7 | }: Readonly<{ 8 | children: React.ReactNode; 9 | params: { 10 | locale: string; 11 | }; 12 | }>) { 13 | return ( 14 | 15 |
{children}
16 |
17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /src/app/[locale]/not-found.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import Image from 'next/image'; 6 | import { useRouter } from 'next/navigation'; 7 | 8 | // Page not found 9 | export default function NotFound() { 10 | const router = useRouter(); 11 | const t = useI18n(); 12 | 13 | return ( 14 |
15 |
16 |
17 |
18 |

19 | {t('not_found.title')} 20 |

21 |

22 | {t('not_found.description')} 23 |

24 |
25 |
26 | 33 |
34 |
35 |
36 |
37 | Image 44 |
45 |
46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /src/app/[locale]/page.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | LandingPageFAQ, 3 | LandingPageFeatures, 4 | LandingPageFooter, 5 | LandingPageHeader, 6 | LandingPageHero, 7 | LandingPagePricing, 8 | LandingPageWaitlist, 9 | } from '@/libs/components/landing-page'; 10 | 11 | export default function Page() { 12 | return ( 13 |
14 | 15 |
16 | 17 |
18 |
19 |
20 | 21 |
22 | 23 |
24 | 25 |
26 | 27 | 28 |
29 |
30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/app/api/admin/resend/route.ts: -------------------------------------------------------------------------------- 1 | import { isUserAuthorServerOrThrow } from '@/libs/admin'; 2 | import { sendEmail } from '@/libs/resend/sendEmail'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | const AUTHOR_EMAIL = process.env.NEXT_PUBLIC_AUTHOR_EMAIL; 6 | 7 | // Send an email with Resend API 8 | export async function POST(req: Request, res: Response) { 9 | try { 10 | // Only allow the author to perform this action 11 | isUserAuthorServerOrThrow(); 12 | 13 | const { data, error } = await sendEmail({ 14 | to: `${AUTHOR_EMAIL}`, 15 | subject: 'Hello World', 16 | html: '

Congrats on sending an email!

', 17 | }); 18 | 19 | if (error) { 20 | return NextResponse.json({ error }, { status: 500 }); 21 | } 22 | 23 | return NextResponse.json(data); 24 | } catch (error) { 25 | console.log('error', error); 26 | return NextResponse.json({ error }, { status: 500 }); 27 | } 28 | } -------------------------------------------------------------------------------- /src/app/api/admin/todo/boilerplate/boilerplate.utils.ts: -------------------------------------------------------------------------------- 1 | import { TODO_ITEM_CATEGORIES } from '@/libs/database'; 2 | import { faker } from '@faker-js/faker'; 3 | import { shuffle } from 'lodash'; 4 | 5 | export function generateMockTodoItem() { 6 | return { 7 | title: faker.lorem.sentence(), 8 | description: faker.lorem.paragraph(), 9 | // Randomly set the due date to be in the future or in the past 10 | dueDate: 11 | Math.random() > 0.5 12 | ? faker.date.soon({ 13 | days: 60, 14 | }) 15 | : faker.date.recent({ 16 | days: 60, 17 | }), 18 | done: Boolean(Math.random() > 0.5), 19 | category: shuffle(TODO_ITEM_CATEGORIES)[0], 20 | }; 21 | } 22 | -------------------------------------------------------------------------------- /src/app/api/admin/todo/boilerplate/route.ts: -------------------------------------------------------------------------------- 1 | import { isUserAuthorServerOrThrow } from '@/libs/admin'; 2 | import { getSignedInUser, prisma } from '@/libs/database'; 3 | import { createTodoItem } from '@/libs/database/functions/todo/createTodoItem'; 4 | import { NextResponse } from 'next/server'; 5 | 6 | import { generateMockTodoItem } from './boilerplate.utils'; 7 | 8 | // Sets the boilerplate 9 | export async function POST(req: Request, res: Response) { 10 | try { 11 | // Only allow the author to perform this action 12 | isUserAuthorServerOrThrow(); 13 | 14 | // Create 50 boilerplate todo items 15 | const responses = []; 16 | for (let i = 0; i < 50; i++) { 17 | const airesponse = await createTodoItem(generateMockTodoItem()); 18 | responses.push(airesponse); 19 | } 20 | 21 | return NextResponse.json({ responses }); 22 | } catch (error) { 23 | console.log('error', error); 24 | return NextResponse.json({ error }, { status: 500 }); 25 | } 26 | } 27 | 28 | // Delete all boilerplate todo items 29 | export async function DELETE(req: Request, res: Response) { 30 | try { 31 | // Only allow the author to perform this action 32 | isUserAuthorServerOrThrow(); 33 | 34 | const signedInUser = await getSignedInUser(); 35 | if (!signedInUser) throw new Error('User not signed in'); 36 | 37 | // Delete all todo items for the signed in user (author) 38 | await prisma.todoItem.deleteMany({ 39 | where: { 40 | userId: signedInUser.id, 41 | }, 42 | }); 43 | 44 | return NextResponse.json(true); 45 | } catch (error) { 46 | console.log('error', error); 47 | return NextResponse.json({ error }, { status: 500 }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/app/api/todo/[id]/route.ts: -------------------------------------------------------------------------------- 1 | import { deleteTodoItem, updateTodoItem } from '@/libs/database/functions/todo'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | // Update a todo item by id for the signed in user 5 | export async function PUT( 6 | req: Request, 7 | { params }: { params: { id: string } }, 8 | ) { 9 | try { 10 | // Todo item ID to update 11 | const id = params.id; 12 | 13 | const body = await req.json(); 14 | 15 | // Update the todo item 16 | const queryResponse = await updateTodoItem({ 17 | id: id, 18 | ...body, 19 | }); 20 | return NextResponse.json(queryResponse); 21 | } catch (error) { 22 | return NextResponse.json({ error }, { status: 500 }); 23 | } 24 | } 25 | 26 | // Delete a todo item by id for the signed in user 27 | export async function DELETE( 28 | req: Request, 29 | { params }: { params: { id: string } }, 30 | ) { 31 | try { 32 | const id = params.id; 33 | 34 | const queryResponse = await deleteTodoItem({ id }); 35 | return NextResponse.json(queryResponse); 36 | } catch (error) { 37 | return NextResponse.json({ error }, { status: 500 }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/app/api/todo/new/route.ts: -------------------------------------------------------------------------------- 1 | import { createTodoItem } from '@/libs/database'; 2 | import { NextResponse } from 'next/server'; 3 | 4 | // Create a new todo item for the signed in user 5 | export async function POST(req: Request) { 6 | try { 7 | const body = await req.json(); 8 | 9 | const queryResponse = await createTodoItem(body); 10 | return NextResponse.json(queryResponse); 11 | } catch (error) { 12 | return NextResponse.json({ error }, { status: 500 }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/api/todo/route.ts: -------------------------------------------------------------------------------- 1 | import { getTodoItems } from '@/libs/database'; 2 | import { NextRequest, NextResponse } from 'next/server'; 3 | 4 | // Get all todo items for the signed in user from the database 5 | export async function GET(req: NextRequest) { 6 | try { 7 | const searchParams = req.nextUrl.searchParams; 8 | 9 | // Get the page and skip from the URL search params 10 | const take = 10; 11 | const page = searchParams.get('page') || '1'; 12 | const skip = page ? parseInt(page) * take : undefined; 13 | 14 | // Get the optional param status from the URL search params 15 | const status = searchParams.get('status'); 16 | const done = 17 | status === 'done' ? true : status === 'pending' ? false : undefined; 18 | 19 | // Get the todo items 20 | const todoItems = await getTodoItems({ 21 | skip, 22 | take, 23 | done, 24 | }); 25 | return NextResponse.json(todoItems); 26 | } catch (error) { 27 | return NextResponse.json({ error }, { status: 500 }); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/app/api/user/route.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCIES } from '@/libs/database'; 2 | import { updateUser } from '@/libs/database/functions/user'; 3 | import { NextResponse } from 'next/server'; 4 | import { z } from 'zod'; 5 | 6 | // User update schema 7 | const userUpdateSchema = z.object({ 8 | defaultCurrency: z.enum([CURRENCIES[0], ...CURRENCIES.slice(1)]).optional(), 9 | }); 10 | 11 | // Update the signed in user - we dont need the user id, as it will be fetched from the session 12 | export async function PUT(req: Request) { 13 | try { 14 | const body = await req.json(); 15 | 16 | // Validate the request body 17 | const userData = userUpdateSchema.parse(body); 18 | 19 | // Update the user 20 | const queryResponse = await updateUser(userData); 21 | 22 | return NextResponse.json(queryResponse); 23 | } catch (error) { 24 | return NextResponse.json({ error }, { status: 500 }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/api/webhooks/clerk/route.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/libs/database'; 2 | import type { WebhookEvent } from '@clerk/nextjs/server'; 3 | import { NextResponse } from 'next/server'; 4 | 5 | // Clerk Webhook: create or delete a user in the database by Clerk ID 6 | export async function POST(req: Request) { 7 | try { 8 | // Parse the Clerk Webhook event 9 | const evt = (await req.json()) as WebhookEvent; 10 | 11 | const { id: clerkUserId } = evt.data; 12 | if (!clerkUserId) 13 | return NextResponse.json( 14 | { error: 'No user ID provided' }, 15 | { status: 400 }, 16 | ); 17 | 18 | // Create or delete a user in the database based on the Clerk Webhook event 19 | let user = null; 20 | switch (evt.type) { 21 | case 'user.created': { 22 | const { email_addresses = [] } = evt.data; 23 | const email = email_addresses?.[0]?.email_address ?? ''; 24 | 25 | if (!email) 26 | return NextResponse.json( 27 | { error: 'No email provided' }, 28 | { status: 400 }, 29 | ); 30 | 31 | user = await prisma.user.upsert({ 32 | where: { 33 | clerkUserId, 34 | }, 35 | update: { 36 | clerkUserId, 37 | email, 38 | }, 39 | create: { 40 | clerkUserId, 41 | email, 42 | }, 43 | }); 44 | break; 45 | } 46 | case 'user.deleted': { 47 | user = await prisma.user.delete({ 48 | where: { 49 | clerkUserId, 50 | }, 51 | }); 52 | break; 53 | } 54 | default: 55 | break; 56 | } 57 | 58 | return NextResponse.json({ user }); 59 | } catch (error) { 60 | return NextResponse.json({ error }, { status: 500 }); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/api/webhooks/lemonsqueezy/route.ts: -------------------------------------------------------------------------------- 1 | import { 2 | processWebhookEvent, 3 | storeWebhookEvent, 4 | } from '@/libs/lemon-squeezy/actions'; 5 | import { webhookHasMeta } from '@/libs/lemon-squeezy/typeguards'; 6 | import { NextResponse } from 'next/server'; 7 | import crypto from 'node:crypto'; 8 | 9 | // Lemon Squeezt Webhook: process a subscription event 10 | export async function POST(req: Request) { 11 | try { 12 | if (!process.env.LEMONSQUEEZY_WEBHOOK_SECRET) { 13 | return new Response( 14 | 'Lemon Squeezy Webhook Secret not set in .env', 15 | { 16 | status: 500, 17 | }, 18 | ); 19 | } 20 | const secret = process.env.LEMONSQUEEZY_WEBHOOK_SECRET; 21 | 22 | // Check the request signature 23 | const rawBody = await req.text(); 24 | const hmac = crypto.createHmac('sha256', secret); 25 | const digest = Buffer.from(hmac.update(rawBody).digest('hex'), 'utf8'); 26 | const signature = Buffer.from( 27 | req.headers.get('X-Signature') || '', 28 | 'utf8', 29 | ); 30 | 31 | if (!crypto.timingSafeEqual(digest, signature)) { 32 | throw new Error('Invalid signature.'); 33 | } 34 | 35 | // Parse the Lemon Squeezy event 36 | const data = JSON.parse(rawBody) as unknown; 37 | 38 | // Type guard to check if the object has a 'meta' property. 39 | if (webhookHasMeta(data)) { 40 | const webhookEvent = await storeWebhookEvent( 41 | data.meta.event_name, 42 | data, 43 | ); 44 | 45 | await processWebhookEvent(webhookEvent); 46 | 47 | return NextResponse.json({ success: true }); 48 | } 49 | 50 | return NextResponse.json({ error: 'Data invalid' }, { status: 400 }); 51 | } catch (error) { 52 | return NextResponse.json({ error }, { status: 500 }); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/app/global-error.jsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as Sentry from '@sentry/nextjs'; 4 | import Error from 'next/error'; 5 | import { useEffect } from 'react'; 6 | 7 | export default function GlobalError({ error }) { 8 | useEffect(() => { 9 | console.error('GlobalError', error); 10 | Sentry?.captureException(error); 11 | }, [error]); 12 | 13 | return ( 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | GoogleAnalytics, 3 | LemonSqueezy, 4 | ToastProvider, 5 | TooltipProviderComponent, 6 | } from '@/libs/providers'; 7 | import { ThemeProvider } from '@/libs/providers/theme-provider'; 8 | import { ClerkLoaded, ClerkLoading, ClerkProvider } from '@clerk/nextjs'; 9 | import { Analytics } from '@vercel/analytics/react'; 10 | import type { Metadata } from 'next'; 11 | import { Lato } from 'next/font/google'; 12 | 13 | import '../globals.css'; 14 | import Loading from './loading'; 15 | 16 | // Load the fonts 17 | const lato = Lato({ 18 | subsets: ['latin'], 19 | variable: '--font-lato', 20 | display: 'swap', 21 | weight: ['300', '400', '700'], 22 | }); 23 | 24 | // Metadata for the app 25 | export const metadata: Metadata = { 26 | title: 'SaasterKit', 27 | description: 'A Next.js Boilerplate Kit for SaaS apps', 28 | keywords: [ 29 | 'nextjs', 30 | 'saas', 31 | 'boilerplate', 32 | 'kit', 33 | 'starter', 34 | 'template', 35 | 'prisma', 36 | 'postgresql', 37 | 'supabase', 38 | 'clerk', 39 | 'resend', 40 | 'shadcn', 41 | 'tailwindcss', 42 | 'typescript', 43 | ], 44 | }; 45 | 46 | export default async function RootLayout({ 47 | children, 48 | }: Readonly<{ 49 | children: React.ReactNode; 50 | }>) { 51 | return ( 52 | 58 | 59 | 60 | 61 | 67 | 68 | 69 | 70 | {children} 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | ); 81 | } 82 | -------------------------------------------------------------------------------- /src/app/loading.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Loading } from '@/libs/components'; 4 | 5 | export default function LoadingPage() { 6 | return ; 7 | } 8 | -------------------------------------------------------------------------------- /src/app/not-found.jsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import Error from 'next/error'; 4 | 5 | export default function NotFound() { 6 | return ( 7 | 8 | 9 | 10 | 11 | 12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /src/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | html { 7 | -webkit-text-size-adjust: 100%; 8 | font-family: var(--font-lato), ui-sans-serif, system-ui, -apple-system, 9 | BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, 10 | Noto Sans, sans-serif, Apple Color Emoji, Segoe UI Emoji, 11 | Segoe UI Symbol, Noto Color Emoji; 12 | line-height: 1.5; 13 | tab-size: 4; 14 | scroll-behavior: smooth; 15 | } 16 | body { 17 | font-family: inherit; 18 | line-height: inherit; 19 | margin: 0; 20 | background: black; 21 | } 22 | } 23 | 24 | @layer base { 25 | * { 26 | @apply border-border; 27 | scroll-margin-top: 100px; 28 | } 29 | 30 | h1 { 31 | @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; 32 | } 33 | 34 | h2 { 35 | @apply scroll-m-20 text-3xl font-semibold tracking-tight; 36 | } 37 | 38 | h3 { 39 | @apply scroll-m-20 text-2xl font-semibold tracking-tight; 40 | } 41 | 42 | h4 { 43 | @apply scroll-m-20 text-xl font-semibold tracking-tight; 44 | } 45 | 46 | p { 47 | @apply leading-7; 48 | } 49 | 50 | blockquote { 51 | @apply mt-6 border-l-2 pl-6 italic; 52 | } 53 | 54 | code { 55 | @apply relative rounded bg-muted px-[0.3rem] py-[0.2rem] font-mono text-sm font-semibold; 56 | } 57 | } 58 | 59 | @layer base { 60 | :root { 61 | --background: 0 0% 100%; 62 | --foreground: 222.2 84% 4.9%; 63 | --card: 0 0% 100%; 64 | --card-foreground: 222.2 84% 4.9%; 65 | --popover: 0 0% 100%; 66 | --popover-foreground: 222.2 84% 4.9%; 67 | --primary: 221.2 83.2% 53.3%; 68 | --primary-foreground: 210 40% 98%; 69 | --secondary: 210 40% 96.1%; 70 | --secondary-foreground: 222.2 47.4% 11.2%; 71 | --tertiary: 24.6 95% 53.1%; 72 | --tertiary-foreground: 60 9.1% 97.8%; 73 | --muted: 210 40% 96.1%; 74 | --muted-foreground: 215.4 16.3% 46.9%; 75 | --accent: 210 40% 96.1%; 76 | --accent-foreground: 222.2 47.4% 11.2%; 77 | --destructive: 0 84.2% 60.2%; 78 | --destructive-foreground: 210 40% 98%; 79 | --border: 214.3 31.8% 91.4%; 80 | --input: 214.3 31.8% 91.4%; 81 | --ring: 221.2 83.2% 53.3%; 82 | --radius: 0.5rem; 83 | } 84 | 85 | .dark { 86 | --background: 222.2 84% 4.9%; 87 | --foreground: 210 40% 98%; 88 | --card: 222.2 84% 4.9%; 89 | --card-foreground: 210 40% 98%; 90 | --popover: 222.2 84% 4.9%; 91 | --popover-foreground: 210 40% 98%; 92 | --primary: 217.2 91.2% 59.8%; 93 | --primary-foreground: 222.2 47.4% 11.2%; 94 | --secondary: 217.2 32.6% 17.5%; 95 | --secondary-foreground: 210 40% 98%; 96 | --tertiary: 20.5 90.2% 48.2%; 97 | --tertiary-foreground: 60 9.1% 97.8%; 98 | --muted: 217.2 32.6% 17.5%; 99 | --muted-foreground: 215 20.2% 65.1%; 100 | --accent: 217.2 32.6% 17.5%; 101 | --accent-foreground: 210 40% 98%; 102 | --destructive: 0 62.8% 30.6%; 103 | --destructive-foreground: 210 40% 98%; 104 | --border: 217.2 32.6% 17.5%; 105 | --input: 217.2 32.6% 17.5%; 106 | --ring: 224.3 76.3% 48%; 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/libs/admin/index.ts: -------------------------------------------------------------------------------- 1 | export * from './utils'; 2 | -------------------------------------------------------------------------------- /src/libs/admin/utils.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@clerk/nextjs'; 2 | import { UserResource } from '@clerk/types'; 3 | 4 | // Checks if the user is the author CLIENT-SIDE. 5 | export const isUserAuthor = (user?: UserResource | null) => { 6 | return ( 7 | user?.primaryEmailAddress?.emailAddress === 8 | process.env['NEXT_PUBLIC_AUTHOR_EMAIL'] 9 | ); 10 | }; 11 | 12 | // Checks if the user is the author SERVER-SIDE. 13 | export const isUserAuthorServer = () => { 14 | const { sessionClaims } = auth(); 15 | return ( 16 | sessionClaims?.['primary_email_address'] === 17 | process.env['NEXT_PUBLIC_AUTHOR_EMAIL'] 18 | ); 19 | }; 20 | 21 | // Checks if the user is the author SERVER-SIDE and throws an error if it's not. 22 | export const isUserAuthorServerOrThrow = async () => { 23 | const isAuthor = isUserAuthorServer(); 24 | if (!isAuthor) { 25 | throw new Error('Unauthorized'); 26 | } 27 | return isAuthor; 28 | }; 29 | -------------------------------------------------------------------------------- /src/libs/components/DarkModeToggle.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { MoonIcon, SunIcon } from '@radix-ui/react-icons'; 4 | import { useTheme } from 'next-themes'; 5 | 6 | import { useI18n } from '../locales/client'; 7 | import { Button } from '../ui/button'; 8 | import { 9 | DropdownMenu, 10 | DropdownMenuCheckboxItem, 11 | DropdownMenuContent, 12 | DropdownMenuTrigger, 13 | } from '../ui/dropdown-menu'; 14 | 15 | // Sets the theme based on the user's preference 16 | export function DarkModeToggle() { 17 | const t = useI18n(); 18 | const { setTheme, theme } = useTheme(); 19 | 20 | return ( 21 | 22 | 23 | 28 | 29 | 30 | setTheme('light')} 33 | > 34 | {t('theme.light')} 35 | 36 | setTheme('dark')} 39 | > 40 | {t('theme.dark')} 41 | 42 | setTheme('system')} 45 | > 46 | {t('theme.system')} 47 | 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/libs/components/DeleteElementWithAlertDialog.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { 5 | AlertDialog, 6 | AlertDialogAction, 7 | AlertDialogCancel, 8 | AlertDialogContent, 9 | AlertDialogDescription, 10 | AlertDialogFooter, 11 | AlertDialogHeader, 12 | AlertDialogTitle, 13 | } from '@/libs/ui/alert-dialog'; 14 | import { useRouter } from 'next/navigation'; 15 | import { toast } from 'react-toastify'; 16 | 17 | // Reusable component to delete an element through an alert dialog 18 | export function DeleteElementWithAlertDialog({ 19 | onClose, 20 | onDeleted, 21 | deleteUrl, 22 | }: { 23 | onClose: () => void; 24 | onDeleted?: () => void; // Callback function to execute after the element is deleted 25 | deleteUrl: string; // URL to delete the element by ID 26 | }) { 27 | const router = useRouter(); 28 | const t = useI18n(); 29 | 30 | // Labels 31 | const title = t('alert_dialog.delete_element.title'); 32 | const description = t('alert_dialog.delete_element.description'); 33 | const pending = t('alert_dialog.delete_element.pending'); 34 | const success = t('alert_dialog.delete_element.success'); 35 | const error = t('alert_dialog.delete_element.error'); 36 | 37 | // on delete function 38 | const onDelete = async () => { 39 | await toast.promise( 40 | fetch(deleteUrl, { 41 | method: 'DELETE', 42 | }).then(async (res) => { 43 | if (!res.ok) { 44 | throw new Error(error); 45 | } 46 | 47 | // Refresh the page 48 | router.refresh(); 49 | onDeleted?.(); 50 | }), 51 | { 52 | pending, 53 | success, 54 | error, 55 | }, 56 | ); 57 | }; 58 | 59 | return ( 60 | { 63 | if (!isOpen) { 64 | onClose(); 65 | } 66 | }} 67 | > 68 | 69 | 70 | {title} 71 | 72 | {description} 73 | 74 | 75 | 76 | 77 | {t('alert_dialog.cancel')} 78 | 79 | 80 | {t('alert_dialog.continue')} 81 | 82 | 83 | 84 | 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /src/libs/components/ElementsTable.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { 4 | Table, 5 | TableBody, 6 | TableCell, 7 | TableHead, 8 | TableHeader, 9 | TableRow, 10 | } from '@/libs/ui/table'; 11 | 12 | import { cn } from '../utils'; 13 | 14 | type ElementsTableColumn = { 15 | title: string; 16 | key?: string; 17 | render?: (row: any) => React.ReactNode; 18 | smHidden?: boolean; // Hide on small screens 19 | }; 20 | 21 | type Row = { 22 | id: string; 23 | [key: string]: any; 24 | }; 25 | 26 | // Reusable table component 27 | export function ElementsTable({ 28 | columns, 29 | rows, 30 | onSelectRow, 31 | selectedRow, 32 | }: { 33 | columns: ElementsTableColumn[]; 34 | rows: Row[]; 35 | onSelectRow?: (row: Row) => void; 36 | selectedRow?: Row | null; 37 | }) { 38 | return ( 39 | 40 | 41 | 42 | {columns.map((column) => ( 43 | 49 | {column.title} 50 | 51 | ))} 52 | 53 | 54 | 55 | {rows.map((row) => ( 56 | onSelectRow?.(row)} 63 | > 64 | { 65 | // Render each column 66 | columns.map((column) => ( 67 | 76 | {column.render 77 | ? column.render(row) 78 | : column.key 79 | ? row[column.key] 80 | : ''} 81 | 82 | )) 83 | } 84 | 85 | ))} 86 | 87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/libs/components/GlobalCurrencySelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useDisplayCurrency } from '@/libs/cookies/currency/useDisplayCurrency'; 4 | import { CURRENCIES } from '@/libs/database'; 5 | import { 6 | Select, 7 | SelectContent, 8 | SelectItem, 9 | SelectTrigger, 10 | SelectValue, 11 | } from '@/libs/ui/select'; 12 | 13 | // Global currency selector. Allows the user to change the currency displayed in the app. Saves the currency in a cookie. 14 | export function GlobalCurrencySelector() { 15 | const { displayCurrency, setDisplayCurrency } = useDisplayCurrency(); 16 | 17 | return ( 18 | 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/libs/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | export function Loading() { 2 | return ( 3 |
4 |
5 |
6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/libs/components/LocaleSelector.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useChangeLocale, useCurrentLocale } from '@/libs/locales/client'; 4 | import { LOCALES } from '@/libs/locales/locale-middleware'; 5 | import { Button } from '@/libs/ui/button'; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuCheckboxItem, 9 | DropdownMenuContent, 10 | DropdownMenuTrigger, 11 | } from '@/libs/ui/dropdown-menu'; 12 | 13 | export function LocaleSelector() { 14 | const changeLocale = useChangeLocale({ preserveSearchParams: true }); 15 | const locale = useCurrentLocale(); 16 | 17 | return ( 18 | 19 | 20 | 23 | 24 | 25 | {LOCALES.map((c) => ( 26 | changeLocale(c)} 30 | > 31 | {c.toUpperCase()} 32 | 33 | ))} 34 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/libs/components/dashboard/DashboardSidebar.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Tooltip, TooltipContent, TooltipTrigger } from '@/libs/ui/tooltip'; 4 | import { cn } from '@/libs/utils'; 5 | import { Atom } from 'lucide-react'; 6 | import Link from 'next/link'; 7 | import { usePathname } from 'next/navigation'; 8 | import { createElement } from 'react'; 9 | 10 | import { DarkModeToggle } from '../DarkModeToggle'; 11 | import { LocaleSelector } from '../LocaleSelector'; 12 | import { useSidenavRoutes } from './useSidenavRoutes'; 13 | 14 | // Sidenav routes base for the dashboard 15 | const SIDENAV_ROUTES_BASE = '/dashboard'; 16 | 17 | export function DashboardSidebar() { 18 | // Get localised sidenav routes 19 | const SIDENAV_ROUTES = useSidenavRoutes(); 20 | 21 | // Get the active route based on the current pathname 22 | const pathname = usePathname(); 23 | const activeRoute = SIDENAV_ROUTES?.find( 24 | ({ href }) => 25 | href === pathname || 26 | (href !== SIDENAV_ROUTES_BASE && pathname?.includes(href)), 27 | ); 28 | 29 | return ( 30 |
31 | 61 | 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/libs/components/dashboard/DashboardStatsCard.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Badge } from '@/libs/ui/badge'; 4 | import { 5 | Card, 6 | CardContent, 7 | CardDescription, 8 | CardFooter, 9 | CardHeader, 10 | CardTitle, 11 | } from '@/libs/ui/card'; 12 | import { Progress } from '@/libs/ui/progress'; 13 | import { cn } from '@/libs/utils'; 14 | import { isNumber, isString } from 'lodash'; 15 | 16 | export function DashboardStatsCard({ 17 | title, 18 | value, 19 | description, 20 | badge, 21 | progress, 22 | className, 23 | variant, 24 | }: { 25 | title: string; 26 | value: string; 27 | description?: string | React.ReactNode; 28 | badge: string; 29 | progress?: number; 30 | className?: string; 31 | variant?: 'default' | 'danger'; 32 | }) { 33 | return ( 34 | 35 | 36 |
37 | {title} 38 | 39 | {badge} 40 | 41 |
42 | 48 | {value} 49 | 50 |
51 | 52 | {isString(description) ? ( 53 |

54 | {description} 55 |

56 | ) : ( 57 | description 58 | )} 59 |
60 | {isNumber(progress) && ( 61 | 62 | 63 | 64 | )} 65 |
66 | ); 67 | } 68 | -------------------------------------------------------------------------------- /src/libs/components/dashboard/SubscribeButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { CreditCard } from 'lucide-react'; 5 | import Link from 'next/link'; 6 | 7 | import { Button } from '../../ui/button'; 8 | 9 | export function SubscribeButton() { 10 | const t = useI18n(); 11 | 12 | return ( 13 | 14 | 18 | 19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/libs/components/dashboard/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DashboardHeader'; 2 | export * from './DashboardSidebar'; 3 | export * from './DashboardStatsCard'; 4 | -------------------------------------------------------------------------------- /src/libs/components/dashboard/useSidenavRoutes.ts: -------------------------------------------------------------------------------- 1 | import { useI18n } from '@/libs/locales/client'; 2 | import { Home, ListTodo, Settings, User } from 'lucide-react'; 3 | 4 | // Sidenav routes with their translated labels, icons, and hrefs 5 | export function useSidenavRoutes() { 6 | const t = useI18n(); 7 | 8 | return [ 9 | { 10 | label: t('dashboard.nav.dashboard'), 11 | Icon: Home, 12 | href: '/dashboard', 13 | }, 14 | { 15 | label: t('dashboard.nav.todo'), 16 | Icon: ListTodo, 17 | href: '/dashboard/todo', 18 | }, 19 | { 20 | label: t('dashboard.nav.my_account'), 21 | Icon: User, 22 | href: '/dashboard/my-account', 23 | }, 24 | { 25 | label: t('dashboard.nav.settings'), 26 | Icon: Settings, 27 | href: '/dashboard/settings', 28 | }, 29 | ]; 30 | } 31 | -------------------------------------------------------------------------------- /src/libs/components/form/FormInput.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Input } from '@/libs/ui/input'; 4 | import { Label } from '@/libs/ui/label'; 5 | import { ReactNode } from 'react'; 6 | import { Controller, useFormContext } from 'react-hook-form'; 7 | 8 | export function FormInput({ 9 | formInputName, 10 | label, 11 | inputProps, 12 | }: { 13 | formInputName: string; 14 | label: string | ReactNode; 15 | inputProps?: React.InputHTMLAttributes; 16 | }) { 17 | const { control } = useFormContext(); 18 | 19 | return ( 20 |
21 | 22 | ( 26 | 33 | )} 34 | /> 35 |
36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/libs/components/form/FormInputDate.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import { Calendar } from '@/libs/ui/calendar'; 6 | import { Label } from '@/libs/ui/label'; 7 | import { Popover, PopoverContent, PopoverTrigger } from '@/libs/ui/popover'; 8 | import { cn, formatDateShort } from '@/libs/utils'; 9 | import { CalendarIcon } from 'lucide-react'; 10 | import moment from 'moment'; 11 | import { Controller, useFormContext } from 'react-hook-form'; 12 | 13 | export function FormInputDate({ 14 | formInputName, 15 | label, 16 | calendarProps, 17 | }: { 18 | formInputName: string; 19 | label: string; 20 | calendarProps?: any; // fixme: should be React.ComponentProps 21 | }) { 22 | const t = useI18n(); 23 | const { control } = useFormContext(); 24 | 25 | return ( 26 |
27 | 28 | ( 32 | 33 | 34 | 50 | 51 | 55 | { 62 | field.onChange(date); 63 | }} 64 | /> 65 | 66 | 67 | )} 68 | /> 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/libs/components/form/FormInputSwitch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Label } from '@/libs/ui/label'; 4 | import { Switch } from '@/libs/ui/switch'; 5 | import { ReactNode } from 'react'; 6 | import { Controller, useFormContext } from 'react-hook-form'; 7 | 8 | export function FormInputSwitch({ 9 | formInputName, 10 | label, 11 | onLabel, 12 | offLabel, 13 | }: { 14 | formInputName: string; 15 | label: string | ReactNode; 16 | onLabel?: string; 17 | offLabel?: string; 18 | }) { 19 | const { control } = useFormContext(); 20 | 21 | return ( 22 |
23 | 24 | ( 28 |
29 | 32 | 36 | field.onChange(checked) 37 | } 38 | /> 39 |
40 | )} 41 | /> 42 |
43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/libs/components/form/FormSelect.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Label } from '@/libs/ui/label'; 4 | import { 5 | Select, 6 | SelectContent, 7 | SelectItem, 8 | SelectTrigger, 9 | SelectValue, 10 | } from '@/libs/ui/select'; 11 | import { Controller, useFormContext } from 'react-hook-form'; 12 | 13 | export function FormSelect({ 14 | formSelectName, 15 | label, 16 | placeholder, 17 | selectItems, 18 | renderSelectItem, 19 | }: { 20 | formSelectName: string; 21 | label: string; 22 | placeholder: string; 23 | selectItems: string[]; 24 | renderSelectItem?: (item: string) => React.ReactNode; 25 | }) { 26 | const { control } = useFormContext(); 27 | 28 | return ( 29 |
30 | 31 | ( 35 | 51 | )} 52 | /> 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /src/libs/components/form/index.ts: -------------------------------------------------------------------------------- 1 | export * from './FormInput'; 2 | export * from './FormInputDate'; 3 | export * from './FormInputSwitch'; 4 | export * from './FormSelect'; 5 | 6 | -------------------------------------------------------------------------------- /src/libs/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './DarkModeToggle'; 2 | export * from './DeleteElementWithAlertDialog'; 3 | export * from './ElementsTable'; 4 | export * from './Loading'; 5 | export * from './dashboard'; 6 | export * from './form'; 7 | -------------------------------------------------------------------------------- /src/libs/components/landing-page/LandingPageCTA.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Button } from '@/libs/ui/button'; 4 | 5 | export function LandingPageCTA() { 6 | return ( 7 |
11 |

Fast-Track Your App to Market

12 |

13 | Start strong with pre-configured boilerplate code and focus on 14 | what really matters - your business. 15 |

16 | 19 |
20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/libs/components/landing-page/LandingPageFAQ.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { 5 | Accordion, 6 | AccordionContent, 7 | AccordionItem, 8 | AccordionTrigger, 9 | } from '@/libs/ui/accordion'; 10 | 11 | export function LandingPageFAQ() { 12 | const t = useI18n(); 13 | 14 | // FAQ content 15 | const content = [ 16 | { 17 | title: t('landing.faq.content.0.title'), 18 | description: t('landing.faq.content.0.description'), 19 | }, 20 | 21 | { 22 | title: t('landing.faq.content.1.title'), 23 | description: t('landing.faq.content.1.description'), 24 | }, 25 | 26 | { 27 | title: t('landing.faq.content.2.title'), 28 | description: t('landing.faq.content.2.description'), 29 | }, 30 | { 31 | title: t('landing.faq.content.3.title'), 32 | description: t('landing.faq.content.3.description'), 33 | }, 34 | { 35 | title: t('landing.faq.content.4.title'), 36 | description: t('landing.faq.content.4.description'), 37 | }, 38 | ]; 39 | 40 | return ( 41 |
45 |

46 | Image 51 | {t('landing.faq.title')} 52 |

53 |

54 | {t('landing.faq.description')} 55 |

56 | 57 | {content.map((item) => ( 58 | 59 | {item.title} 60 | {item.description} 61 | 62 | ))} 63 | 64 |
65 | ); 66 | } 67 | -------------------------------------------------------------------------------- /src/libs/components/landing-page/LandingPageFooter.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import { GitHubLogoIcon } from '@radix-ui/react-icons'; 6 | import { MailIcon } from 'lucide-react'; 7 | import Link from 'next/link'; 8 | 9 | export function LandingPageFooter() { 10 | const t = useI18n(); 11 | 12 | return ( 13 | 67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /src/libs/components/landing-page/LandingPagePricing.tsx: -------------------------------------------------------------------------------- 1 | import { prisma } from '@/libs/database'; 2 | import { syncPlans } from '@/libs/lemon-squeezy/actions'; 3 | import { getI18n } from '@/libs/locales/server'; 4 | import { LsSubscriptionPlan } from '@prisma/client'; 5 | 6 | import { Plan } from '../lemon-squeezy/Plan'; 7 | 8 | export async function LandingPagePricing() { 9 | const t = await getI18n(); 10 | 11 | // Get all plans from the database. 12 | let allPlans: LsSubscriptionPlan[] = 13 | await prisma.lsSubscriptionPlan.findMany(); 14 | 15 | // If there are no plans in the database, sync them from Lemon Squeezy. 16 | // You might want to add logic to sync plans periodically or a webhook handler. 17 | if (!allPlans.length) { 18 | allPlans = await syncPlans(); 19 | } 20 | 21 | if (!allPlans.length) { 22 | return

No plans available.

; 23 | } 24 | 25 | return ( 26 |
30 |

31 | Image 36 | {t('landing.pricing.title')} 37 |

38 |

39 | {t('landing.pricing.description')} 40 |

41 |
42 | {allPlans.map((item, index) => ( 43 | 44 | ))} 45 |
46 |
47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /src/libs/components/landing-page/LandingPageWaitlist.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import { Input } from '@/libs/ui/input'; 6 | import { cn } from '@/libs/utils'; 7 | import { noop } from 'lodash'; 8 | import { useState } from 'react'; 9 | 10 | export function LandingPageWaitlist() { 11 | const t = useI18n(); 12 | // Email state 13 | const [email, setEmail] = useState(''); 14 | 15 | return ( 16 |
20 |
21 |
22 |

{t('landing.waitlist.title')}

23 |

24 | {t('landing.waitlist.description')} 25 |

26 |
27 | 28 |
29 |
30 | setEmail(e.target.value)} 35 | className="w-full" 36 | /> 37 | 40 |
41 |
42 |
43 | 44 |
45 | Image 50 |
51 |
52 | ); 53 | } 54 | -------------------------------------------------------------------------------- /src/libs/components/landing-page/index.ts: -------------------------------------------------------------------------------- 1 | export * from './LandingPageCTA'; 2 | export * from './LandingPageFAQ'; 3 | export * from './LandingPageFeatures'; 4 | export * from './LandingPageFooter'; 5 | export * from './LandingPageHeader'; 6 | export * from './LandingPageHero'; 7 | export * from './LandingPagePricing'; 8 | export * from './LandingPageWaitlist'; 9 | -------------------------------------------------------------------------------- /src/libs/components/lemon-squeezy/ChangePlans.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { Plan } from '@/libs/components/lemon-squeezy/Plan'; 4 | import { isValidSubscription } from '@/libs/lemon-squeezy/utils'; 5 | import { useI18n } from '@/libs/locales/client'; 6 | import { 7 | Sheet, 8 | SheetContent, 9 | SheetDescription, 10 | SheetHeader, 11 | SheetTitle, 12 | } from '@/libs/ui/sheet'; 13 | import { LsSubscriptionPlan, LsUserSubscription } from '@prisma/client'; 14 | 15 | export function ChangePlans({ 16 | allPlans, 17 | userSubscriptions, 18 | onClose, 19 | }: { 20 | allPlans: LsSubscriptionPlan[]; 21 | userSubscriptions: LsUserSubscription[]; 22 | onClose: () => void; 23 | }) { 24 | const t = useI18n(); 25 | 26 | const currentPlan = userSubscriptions.find((s) => 27 | isValidSubscription(s.status), 28 | ); 29 | 30 | // Check if the current plan is usage based 31 | const isCurrentPlanUsageBased = currentPlan?.isUsageBased; 32 | 33 | // Get all plans that are usage based or not usage based 34 | const filteredPlans = allPlans 35 | .filter((plan) => { 36 | return isCurrentPlanUsageBased 37 | ? Boolean(plan.isUsageBased) 38 | : Boolean(!plan.isUsageBased); 39 | }) 40 | .sort((a, b) => { 41 | if ( 42 | a.sort === undefined || 43 | a.sort === null || 44 | b.sort === undefined || 45 | b.sort === null 46 | ) { 47 | return 0; 48 | } 49 | 50 | return a.sort - b.sort; 51 | }); 52 | 53 | return ( 54 | { 57 | if (!isOpen) { 58 | onClose(); 59 | } 60 | }} 61 | > 62 | 66 | 67 | 68 | {t('dashboard.my_account.change_plan')} 69 | 70 | 71 | {t('dashboard.my_account.change_plan_description')} 72 | 73 | 74 | 75 | {!userSubscriptions.length || 76 | !allPlans.length || 77 | filteredPlans.length < 2 ? ( 78 |

{t('dashboard.my_account.no_plans_available')}

79 | ) : ( 80 |
81 | {filteredPlans.map((plan, index) => ( 82 | 88 | ))} 89 |
90 | )} 91 |
92 |
93 | ); 94 | } 95 | -------------------------------------------------------------------------------- /src/libs/components/lemon-squeezy/ChangePlansButton.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { useI18n } from '@/libs/locales/client'; 4 | import { Button } from '@/libs/ui/button'; 5 | import { LsSubscriptionPlan, LsUserSubscription } from '@prisma/client'; 6 | import { useState } from 'react'; 7 | 8 | import { ChangePlans } from './ChangePlans'; 9 | 10 | // Renders the change plans button and opens a modal to change plans 11 | export function ChangePlansButton({ 12 | allPlans, 13 | userSubscriptions, 14 | }: { 15 | allPlans: LsSubscriptionPlan[]; 16 | userSubscriptions: LsUserSubscription[]; 17 | }) { 18 | const t = useI18n(); 19 | 20 | const [isChangePlansOpen, setIsChangePlansOpen] = useState(false); 21 | 22 | return ( 23 | <> 24 | {isChangePlansOpen && ( 25 | setIsChangePlansOpen(false)} 29 | /> 30 | )} 31 | 32 | 39 | 40 | ); 41 | } 42 | -------------------------------------------------------------------------------- /src/libs/components/lemon-squeezy/SubscriptionsActions.tsx: -------------------------------------------------------------------------------- 1 | import { getSubscriptionURLs } from '@/libs/lemon-squeezy/actions'; 2 | import { LsUserSubscription } from '@prisma/client'; 3 | 4 | import { SubscriptionsActionsDropdown } from './SubscriptionsActionsDropdown'; 5 | 6 | // RSC that passes the appropiate urls to the SubscriptionsActionsDropdown component based on the userSubscription status 7 | export async function SubscriptionActions({ 8 | userSubscription, 9 | }: { 10 | userSubscription: LsUserSubscription; 11 | }) { 12 | if ( 13 | userSubscription.status === 'expired' || 14 | userSubscription.status === 'cancelled' || 15 | userSubscription.status === 'unpaid' 16 | ) { 17 | return null; 18 | } 19 | 20 | // Get the appropiate urls based on the userSubscription status 21 | const urls = await getSubscriptionURLs(userSubscription.lemonSqueezyId); 22 | 23 | return ( 24 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/libs/cookies/currency/getDisplayCurrency.ts: -------------------------------------------------------------------------------- 1 | import { CURRENCIES, getSignedInUser } from '@/libs/database'; 2 | import { CurrencyEnum, User } from '@prisma/client'; 3 | import { getCookie } from 'cookies-next'; 4 | import { cookies } from 'next/headers'; 5 | 6 | // Get the display currency from the cookie or the signed in user. Optionally pass the signed in user to avoid fetching it again. 7 | export async function getDisplayCurrency(signedInUser?: User | null) { 8 | let displayCurrency = String( 9 | getCookie('currency', { 10 | cookies, 11 | }), 12 | ) as CurrencyEnum; 13 | if (CURRENCIES.indexOf(displayCurrency) === -1) { 14 | const user = signedInUser || (await getSignedInUser()); 15 | displayCurrency = user?.defaultCurrency || 'USD'; 16 | } 17 | 18 | return displayCurrency; 19 | } 20 | -------------------------------------------------------------------------------- /src/libs/cookies/currency/useDisplayCurrency.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { CurrencyEnum } from '@prisma/client'; 4 | import { getCookie, setCookie } from 'cookies-next'; 5 | import { useRouter } from 'next/navigation'; 6 | import { useState } from 'react'; 7 | 8 | import { CURRENCIES } from '../../database'; 9 | import { useSignedInUser } from '../../providers'; 10 | 11 | export function useDisplayCurrency() { 12 | const router = useRouter(); 13 | 14 | // Get the preferred currency from the signed in user or the cookie 15 | const signedInUser = useSignedInUser(); 16 | let defaultCurrency = String(getCookie('currency')) as CurrencyEnum; 17 | // If the currency is not supported or the cookie is corrupted, use the user's default currency 18 | if (CURRENCIES.indexOf(defaultCurrency) === -1) 19 | defaultCurrency = signedInUser?.defaultCurrency || 'USD'; 20 | 21 | // Currency to display in the app 22 | const [currency, setCurrency] = useState( 23 | defaultCurrency as CurrencyEnum, 24 | ); 25 | 26 | // On currency change, update the cookie 27 | const setDisplayCurrency = (value: CurrencyEnum) => { 28 | setCurrency(value); 29 | setCookie('currency', value, { 30 | maxAge: 60 * 60 * 24 * 365, 31 | }); 32 | 33 | // Update the user's default currency if they are signed in 34 | if (signedInUser) { 35 | fetch('/api/user', { 36 | method: 'PUT', 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | }, 40 | body: JSON.stringify({ 41 | defaultCurrency: value, 42 | }), 43 | }); 44 | } 45 | 46 | router.refresh(); 47 | }; 48 | 49 | return { 50 | displayCurrency: currency, 51 | setDisplayCurrency, 52 | }; 53 | } 54 | -------------------------------------------------------------------------------- /src/libs/database/functions/todo/createTodoItem.ts: -------------------------------------------------------------------------------- 1 | import { TodoItem } from '@prisma/client'; 2 | 3 | import { prisma } from '../../prisma-client'; 4 | import { checkParamsAndGetUserOrThrow } from '../../utils'; 5 | 6 | type CreateTodoItemQuery = Partial; 7 | 8 | // Create a todo item for the signed in user 9 | export async function createTodoItem(params?: CreateTodoItemQuery) { 10 | const signedInUser = await checkParamsAndGetUserOrThrow(params, [ 11 | 'title', 12 | 'category', 13 | 'dueDate', 14 | ]); 15 | 16 | // Todo item data 17 | const data = { 18 | userId: signedInUser!.id, 19 | title: params!.title!, 20 | description: params!.description, 21 | category: params!.category!, 22 | dueDate: new Date(params!.dueDate!), 23 | done: params!.done, 24 | }; 25 | 26 | // Create the todo item 27 | const newTodoItem = await prisma.todoItem.create({ 28 | data, 29 | }); 30 | 31 | return newTodoItem; 32 | } 33 | -------------------------------------------------------------------------------- /src/libs/database/functions/todo/deleteTodoItem.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../prisma-client'; 2 | import { checkParamsAndGetUserOrThrow } from '../../utils'; 3 | 4 | type DeleteTodoItemQuery = { 5 | id: string; 6 | }; 7 | 8 | // Delete a todo item by id for the signed in user 9 | export async function deleteTodoItem(params?: DeleteTodoItemQuery) { 10 | const signedInUser = await checkParamsAndGetUserOrThrow(params, ['id']); 11 | 12 | // Delete the todo item by id for the signed in user 13 | const deletedTodoItem = await prisma.todoItem.delete({ 14 | where: { 15 | userId: signedInUser!.id, // Only the signed in user can delete their own todo items 16 | id: params!.id, 17 | }, 18 | }); 19 | 20 | return deletedTodoItem; 21 | } 22 | -------------------------------------------------------------------------------- /src/libs/database/functions/todo/getTodoItems.ts: -------------------------------------------------------------------------------- 1 | import { prisma } from '../../prisma-client'; 2 | import { getSignedInUserOrThrow } from '../../utils'; 3 | 4 | type GetTodoItemsQuery = { 5 | take?: number; 6 | skip?: number; 7 | from?: string; 8 | to?: string; 9 | done?: boolean; 10 | }; 11 | 12 | // Get todo items for the signed in user between the specified dates and status 13 | export async function getTodoItems(params?: GetTodoItemsQuery) { 14 | const signedInUser = await getSignedInUserOrThrow(); 15 | 16 | const where = { 17 | ...(params?.from && params?.to 18 | ? { 19 | createdAt: { 20 | gte: new Date(params.from), 21 | lte: new Date(params.to), 22 | }, 23 | } 24 | : {}), 25 | ...(params?.done ? { done: params.done } : {}), 26 | }; 27 | 28 | return await prisma.todoItem.findMany({ 29 | where: { 30 | ...where, 31 | userId: signedInUser!.id, 32 | }, 33 | take: params?.take, 34 | skip: params?.skip, 35 | orderBy: { 36 | dueDate: 'desc', 37 | }, 38 | }); 39 | } 40 | -------------------------------------------------------------------------------- /src/libs/database/functions/todo/getUserRecentTodoItems.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | import { prisma } from '../../prisma-client'; 4 | import { getSignedInUserOrThrow } from '../../utils'; 5 | 6 | // Get the user's recent todo items 7 | export async function getUserRecentTodoItems() { 8 | const signedInUser = await getSignedInUserOrThrow(); 9 | 10 | return await prisma.todoItem.findMany({ 11 | take: 5, // Only get the first 5 12 | where: { 13 | userId: signedInUser!.id, 14 | dueDate: { 15 | lte: moment().endOf('day').toDate(), // Due date is less than or equal to today 16 | }, 17 | }, 18 | orderBy: { 19 | dueDate: 'desc', 20 | }, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/libs/database/functions/todo/getUserUpcomingTodoItems.ts: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | import { prisma } from '../../prisma-client'; 4 | import { getSignedInUserOrThrow } from '../../utils'; 5 | 6 | // Get the user's upcoming todo items 7 | export async function getUserUpcomingTodoItems() { 8 | const signedInUser = await getSignedInUserOrThrow(); 9 | 10 | return await prisma.todoItem.findMany({ 11 | take: 5, // Only get the first 5 12 | where: { 13 | userId: signedInUser!.id, 14 | dueDate: { 15 | gt: moment().endOf('day').toDate(), // Due date is greater than today 16 | }, 17 | }, 18 | orderBy: { 19 | dueDate: 'asc', 20 | }, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /src/libs/database/functions/todo/index.ts: -------------------------------------------------------------------------------- 1 | export * from './createTodoItem'; 2 | export * from './deleteTodoItem'; 3 | export * from './getTodoItems'; 4 | export * from './getUserRecentTodoItems'; 5 | export * from './getUserUpcomingTodoItems'; 6 | export * from './updateTodoItem'; 7 | 8 | -------------------------------------------------------------------------------- /src/libs/database/functions/todo/updateTodoItem.ts: -------------------------------------------------------------------------------- 1 | import { TodoItem } from '@prisma/client'; 2 | 3 | import { prisma } from '../../prisma-client'; 4 | import { checkParamsAndGetUserOrThrow } from '../../utils'; 5 | 6 | type UpdateTodoItemQuery = Partial; 7 | 8 | // Update a todo item by id for the signed in user 9 | export async function updateTodoItem(params?: UpdateTodoItemQuery) { 10 | const signedInUser = await checkParamsAndGetUserOrThrow(params, ['id']); 11 | 12 | // Todo item data to update 13 | const data = { 14 | title: params!.title, 15 | description: params!.description, 16 | category: params!.category, 17 | dueDate: params!.dueDate ? new Date(params!.dueDate) : undefined, 18 | done: params!.done, 19 | }; 20 | 21 | // Update the todo item 22 | const updatedTodoItem = await prisma.todoItem.update({ 23 | where: { 24 | userId: signedInUser!.id, // Only the signed in user can update their own todo items 25 | id: params!.id, 26 | }, 27 | data, 28 | }); 29 | 30 | return updatedTodoItem; 31 | } 32 | -------------------------------------------------------------------------------- /src/libs/database/functions/user/index.ts: -------------------------------------------------------------------------------- 1 | export * from './updateUser'; 2 | -------------------------------------------------------------------------------- /src/libs/database/functions/user/updateUser.ts: -------------------------------------------------------------------------------- 1 | import { User } from '@prisma/client'; 2 | 3 | import { prisma } from '../../prisma-client'; 4 | import { getSignedInUserOrThrow } from '../../utils'; 5 | 6 | type UpdateUserQuery = Partial; 7 | 8 | export async function updateUser(params?: UpdateUserQuery) { 9 | // Only the signed in user can update their own data 10 | const signedInUser = await getSignedInUserOrThrow(); 11 | 12 | // Update the user 13 | const user = await prisma.user.update({ 14 | where: { 15 | id: signedInUser!.id, 16 | }, 17 | data: { 18 | defaultCurrency: params?.defaultCurrency, 19 | }, 20 | }); 21 | 22 | return user; 23 | } 24 | -------------------------------------------------------------------------------- /src/libs/database/index.ts: -------------------------------------------------------------------------------- 1 | export * from './functions/todo'; 2 | export * from './prisma-client'; 3 | export * from './types'; 4 | export * from './utils'; 5 | -------------------------------------------------------------------------------- /src/libs/database/migrations/20240430143651_/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateEnum 2 | CREATE TYPE "TodoItemCategoryEnum" AS ENUM ('WORK', 'PERSONAL', 'SHOPPING', 'UNSPECIFIED'); 3 | 4 | -- CreateEnum 5 | CREATE TYPE "CurrencyEnum" AS ENUM ('USD', 'ARS'); 6 | 7 | -- CreateTable 8 | CREATE TABLE "User" ( 9 | "id" TEXT NOT NULL, 10 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 11 | "updatedAt" TIMESTAMP(3) NOT NULL, 12 | "clerkUserId" TEXT, 13 | "defaultCurrency" "CurrencyEnum" NOT NULL DEFAULT 'USD', 14 | 15 | CONSTRAINT "User_pkey" PRIMARY KEY ("id") 16 | ); 17 | 18 | -- CreateTable 19 | CREATE TABLE "TodoItem" ( 20 | "id" TEXT NOT NULL, 21 | "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, 22 | "updatedAt" TIMESTAMP(3) NOT NULL, 23 | "title" TEXT NOT NULL, 24 | "description" TEXT, 25 | "category" "TodoItemCategoryEnum" NOT NULL DEFAULT 'UNSPECIFIED', 26 | "dueDate" TIMESTAMP(3) NOT NULL, 27 | "done" BOOLEAN NOT NULL DEFAULT false, 28 | "userId" TEXT NOT NULL, 29 | 30 | CONSTRAINT "TodoItem_pkey" PRIMARY KEY ("id") 31 | ); 32 | 33 | -- CreateIndex 34 | CREATE UNIQUE INDEX "User_clerkUserId_key" ON "User"("clerkUserId"); 35 | 36 | -- AddForeignKey 37 | ALTER TABLE "TodoItem" ADD CONSTRAINT "TodoItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; 38 | -------------------------------------------------------------------------------- /src/libs/database/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" -------------------------------------------------------------------------------- /src/libs/database/prisma-client.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from '@prisma/client'; 2 | 3 | // Singleton pattern to avoid multiple instances of Prisma 4 | const prismaClientSingleton = () => { 5 | return new PrismaClient(); 6 | }; 7 | 8 | declare global { 9 | // eslint-disable-next-line no-var 10 | var prisma: undefined | ReturnType; 11 | } 12 | 13 | export const prisma = globalThis.prisma ?? prismaClientSingleton(); 14 | 15 | if (process.env.NODE_ENV !== 'production') globalThis.prisma = prisma; 16 | -------------------------------------------------------------------------------- /src/libs/database/types.ts: -------------------------------------------------------------------------------- 1 | import { CurrencyEnum, Prisma, TodoItemCategoryEnum } from '@prisma/client'; 2 | 3 | // ------------- Enums 4 | 5 | export const TODO_ITEM_CATEGORIES: TodoItemCategoryEnum[] = [ 6 | 'WORK', 7 | 'PERSONAL', 8 | 'SHOPPING', 9 | 'UNSPECIFIED', 10 | ] as const; 11 | 12 | export const CURRENCIES: CurrencyEnum[] = ['USD', 'ARS'] as const; 13 | 14 | // ------------- Composite types 15 | 16 | export type UserWithTodoItems = Prisma.UserGetPayload<{ 17 | include: { 18 | Todos: true; 19 | }; 20 | }>; 21 | 22 | export type TodoItemWithUser = Prisma.TodoItemGetPayload<{ 23 | include: { 24 | User: true; 25 | }; 26 | }>; 27 | -------------------------------------------------------------------------------- /src/libs/database/utils.ts: -------------------------------------------------------------------------------- 1 | import { auth } from '@clerk/nextjs'; 2 | import { Prisma } from '@prisma/client'; 3 | 4 | import { prisma } from './prisma-client'; 5 | 6 | // Checks that the user is signed in and returns the user from the database that matches the Clerk user ID. 7 | export async function getSignedInUser(include?: Prisma.UserInclude) { 8 | // Get the signed in user ID from Clerk 9 | const authdata = auth(); 10 | const { userId } = authdata; 11 | if (!userId) return null; 12 | 13 | // There's a signed in user, but it might not be in the database yet. 14 | // Try a couple times to get the user from the database. This is a workaround for the race condition between the user signing up and creating the user in the database. 15 | let user = null; 16 | for (let i = 0; i < 10; i++) { 17 | user = await prisma.user.findUnique({ 18 | where: { 19 | clerkUserId: userId, 20 | }, 21 | include, 22 | }); 23 | 24 | if (user) break; 25 | 26 | // Delay a random amount of miliseconds to avoid multiple users being created with the same slug or clerkUserId 27 | await new Promise((resolve) => 28 | setTimeout(resolve, Math.random() * 1000), 29 | ); 30 | } 31 | 32 | return user; 33 | } 34 | 35 | // Checks that the user is signed in and returns the user from the database that matches the Clerk user ID, or throws an error if not. 36 | export async function getSignedInUserOrThrow(include?: Prisma.UserInclude) { 37 | const user = await getSignedInUser(include); 38 | if (!user) throw new Error('User not signed in'); 39 | 40 | return user; 41 | } 42 | 43 | // Checks that all the given parameters are defined, and throws an error if not. 44 | export function checkParamsOrThrow( 45 | params?: Record, 46 | paramsList: string[] = [], 47 | ) { 48 | paramsList.forEach((param) => { 49 | if ( 50 | !params?.[param] && 51 | params?.[param] !== false && 52 | params?.[param] !== 0 53 | ) { 54 | throw new Error(`Missing parameter: ${param}`); 55 | } 56 | }); 57 | } 58 | 59 | // Combines the checkParamsOrThrow and getSignedInUserOrThrow functions. Returns the signed in user. 60 | export function checkParamsAndGetUserOrThrow( 61 | params?: Record, 62 | paramsList: string[] = [], 63 | ) { 64 | checkParamsOrThrow(params, paramsList); 65 | return getSignedInUserOrThrow(); 66 | } 67 | -------------------------------------------------------------------------------- /src/libs/lemon-squeezy/config.ts: -------------------------------------------------------------------------------- 1 | import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js'; 2 | 3 | /** 4 | * Ensures that required environment variables are set and sets up the Lemon 5 | * Squeezy JS SDK. Throws an error if any environment variables are missing or 6 | * if there's an error setting up the SDK. 7 | */ 8 | export function configureLemonSqueezy() { 9 | const requiredVars = [ 10 | 'LEMONSQUEEZY_API_KEY', 11 | 'LEMONSQUEEZY_STORE_ID', 12 | 'LEMONSQUEEZY_WEBHOOK_SECRET', 13 | ]; 14 | 15 | const missingVars = requiredVars.filter((varName) => !process.env[varName]); 16 | 17 | if (missingVars.length > 0) { 18 | throw new Error( 19 | `Missing required LEMONSQUEEZY env variables: ${missingVars.join( 20 | ', ', 21 | )}. Please, set them in your .env file.`, 22 | ); 23 | } 24 | 25 | lemonSqueezySetup({ 26 | apiKey: process.env.LEMONSQUEEZY_API_KEY, 27 | onError: (error) => { 28 | // eslint-disable-next-line no-console -- allow logging 29 | console.error(error); 30 | throw new Error(`Lemon Squeezy API error: ${error.message}`); 31 | }, 32 | }); 33 | } 34 | -------------------------------------------------------------------------------- /src/libs/lemon-squeezy/typeguards.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Check if the value is an object. 3 | */ 4 | function isObject(value: unknown): value is Record { 5 | return typeof value === "object" && value !== null; 6 | } 7 | 8 | /** 9 | * Typeguard to check if the object has a 'meta' property 10 | * and that the 'meta' property has the correct shape. 11 | */ 12 | export function webhookHasMeta(obj: unknown): obj is { 13 | meta: { 14 | event_name: string; 15 | custom_data: { 16 | user_id: string; 17 | }; 18 | }; 19 | } { 20 | if ( 21 | isObject(obj) && 22 | isObject(obj.meta) && 23 | typeof obj.meta.event_name === "string" && 24 | isObject(obj.meta.custom_data) && 25 | typeof obj.meta.custom_data.user_id === "string" 26 | ) { 27 | return true; 28 | } 29 | return false; 30 | } 31 | 32 | /** 33 | * Typeguard to check if the object has a 'data' property and the correct shape. 34 | * 35 | * @param obj - The object to check. 36 | * @returns True if the object has a 'data' property. 37 | */ 38 | export function webhookHasData(obj: unknown): obj is { 39 | data: { 40 | attributes: Record & { 41 | first_subscription_item: { 42 | id: number; 43 | price_id: number; 44 | is_usage_based: boolean; 45 | }; 46 | }; 47 | id: string; 48 | }; 49 | } { 50 | return ( 51 | isObject(obj) && 52 | "data" in obj && 53 | isObject(obj.data) && 54 | "attributes" in obj.data 55 | ); 56 | } 57 | -------------------------------------------------------------------------------- /src/libs/lemon-squeezy/utils.ts: -------------------------------------------------------------------------------- 1 | import { LsUserSubscription } from '@prisma/client'; 2 | 3 | export function formatPrice(priceInCents: string) { 4 | const price = parseFloat(priceInCents); 5 | const dollars = price / 100; 6 | 7 | return new Intl.NumberFormat('en-US', { 8 | style: 'currency', 9 | currency: 'USD', 10 | // Use minimumFractionDigits to handle cases like $59.00 -> $59 11 | minimumFractionDigits: dollars % 1 !== 0 ? 2 : 0, 12 | }).format(dollars); 13 | } 14 | 15 | export function isValidSubscription(status: LsUserSubscription['status']) { 16 | return ( 17 | status !== 'cancelled' && status !== 'expired' && status !== 'unpaid' 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/libs/locales/client.ts: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import { createI18nClient } from 'next-international/client'; 4 | 5 | export const { 6 | useI18n, 7 | useScopedI18n, 8 | I18nProviderClient, 9 | useChangeLocale, 10 | useCurrentLocale, 11 | } = createI18nClient({ 12 | en: () => import('./en'), 13 | es: () => import('./es'), 14 | // Add more locales here 15 | // fr: () => import('./fr'), 16 | }); 17 | -------------------------------------------------------------------------------- /src/libs/locales/locale-middleware.ts: -------------------------------------------------------------------------------- 1 | import { createI18nMiddleware } from 'next-international/middleware'; 2 | import { NextRequest } from 'next/server'; 3 | 4 | // Supported locales 5 | export const LOCALES = [ 6 | 'en', 7 | 'es', 8 | // Add more locales here 9 | ] as const; 10 | 11 | const I18nMiddleware = createI18nMiddleware({ 12 | locales: LOCALES, 13 | defaultLocale: 'en', 14 | urlMappingStrategy: 'rewrite', 15 | }); 16 | 17 | export function localeMiddleware(request: NextRequest) { 18 | return I18nMiddleware(request); 19 | } 20 | -------------------------------------------------------------------------------- /src/libs/locales/server.ts: -------------------------------------------------------------------------------- 1 | // locales/server.ts 2 | import { createI18nServer } from 'next-international/server'; 3 | 4 | export const { getI18n, getScopedI18n, getStaticParams, getCurrentLocale } = 5 | createI18nServer({ 6 | en: () => import('./en'), 7 | es: () => import('./es'), 8 | // Add more locales here 9 | // fr: () => import('./fr'), 10 | }); 11 | -------------------------------------------------------------------------------- /src/libs/providers/google-analytics.tsx: -------------------------------------------------------------------------------- 1 | import Script from 'next/script'; 2 | 3 | export const GoogleAnalytics = () => { 4 | return ( 5 | <> 6 | 21 | 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /src/libs/providers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './google-analytics'; 2 | export * from './lemon-squeezy'; 3 | export * from './locale-provider'; 4 | export * from './signedin-user-provider'; 5 | export * from './theme-provider'; 6 | export * from './toast-provider'; 7 | export * from './tooltip-provider'; 8 | 9 | -------------------------------------------------------------------------------- /src/libs/providers/lemon-squeezy.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | import Script from 'next/script'; 3 | 4 | export const LemonSqueezy = () => { 5 | return ( 6 | <> 7 |