├── .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 |
6 |
--------------------------------------------------------------------------------
/public/images/brix/Arrow 2.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/images/brix/Arrow 6.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/images/brix/Circle 4.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/images/brix/Circle 7.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/images/brix/Line 1.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/images/brix/Line 3.svg:
--------------------------------------------------------------------------------
1 |
6 |
--------------------------------------------------------------------------------
/public/images/brix/Line 6.svg:
--------------------------------------------------------------------------------
1 |
4 |
--------------------------------------------------------------------------------
/public/images/brix/Line 7.svg:
--------------------------------------------------------------------------------
1 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
42 |
43 |
44 |
45 |

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 |
10 |
11 |
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 |