├── src
├── services
│ └── splitwise.service.ts
├── types
│ ├── daisyui.d.ts
│ └── global.d.ts
├── validations
│ └── CounterValidation.ts
├── styles
│ └── global.css
├── app
│ ├── robots.ts
│ ├── sitemap.ts
│ ├── [locale]
│ │ ├── (auth)
│ │ │ ├── (center)
│ │ │ │ ├── layout.tsx
│ │ │ │ ├── sign-in
│ │ │ │ │ └── [[...sign-in]]
│ │ │ │ │ │ └── page.tsx
│ │ │ │ └── sign-up
│ │ │ │ │ └── [[...sign-up]]
│ │ │ │ │ └── page.tsx
│ │ │ ├── dashboard
│ │ │ │ ├── page.tsx
│ │ │ │ ├── user-profile
│ │ │ │ │ └── [[...user-profile]]
│ │ │ │ │ │ └── page.tsx
│ │ │ │ └── layout.tsx
│ │ │ └── layout.tsx
│ │ ├── api
│ │ │ └── splitwise
│ │ │ │ └── groups
│ │ │ │ ├── info
│ │ │ │ └── route.ts
│ │ │ │ └── [groupId]
│ │ │ │ └── expenses
│ │ │ │ └── route.ts
│ │ ├── layout.tsx
│ │ └── page.tsx
│ └── global-error.tsx
├── utils
│ ├── AppConfig.ts
│ ├── Helpers.test.ts
│ └── Helpers.ts
├── components
│ ├── DemoBanner.tsx
│ ├── DemoBadge.tsx
│ ├── Hello.tsx
│ ├── analytics
│ │ ├── PostHogProvider.tsx
│ │ └── PostHogPageView.tsx
│ ├── LocaleSwitcher.tsx
│ ├── VisualSplitwise.tsx
│ ├── CounterForm.tsx
│ ├── GroupExpense.tsx
│ ├── GroupChart.tsx
│ └── Sponsors.tsx
├── libs
│ ├── i18nNavigation.ts
│ ├── Arcjet.ts
│ ├── Logger.ts
│ ├── i18n.ts
│ └── Env.ts
├── lib
│ ├── mongodb
│ │ ├── init.ts
│ │ └── database.ts
│ └── discord
│ │ └── discord.ts
├── instrumentation.ts
├── templates
│ ├── BaseTemplate.stories.tsx
│ ├── BaseTemplate.test.tsx
│ └── BaseTemplate.tsx
├── middleware.ts
├── models
│ └── Expense.ts
└── locales
│ ├── en.json
│ └── fr.json
├── codecov.yml
├── .husky
├── commit-msg
└── pre-commit
├── public
├── favicon.ico
├── favicon-16x16.png
├── favicon-32x32.png
├── apple-touch-icon.png
└── assets
│ └── images
│ ├── sentry-dark.png
│ ├── crowdin-dark.png
│ ├── crowdin-white.png
│ ├── sentry-white.png
│ ├── sevalla-dark.png
│ ├── sevalla-light.png
│ ├── clerk-logo-dark.png
│ ├── better-stack-dark.png
│ ├── better-stack-white.png
│ ├── checkly-logo-dark.png
│ ├── checkly-logo-light.png
│ ├── nextjs-starter-banner.png
│ ├── nextjs-boilerplate-saas.png
│ ├── nextjs-boilerplate-sign-in.png
│ ├── nextjs-boilerplate-sign-up.png
│ ├── arcjet-dark.svg
│ ├── arcjet-light.svg
│ ├── codecov-dark.svg
│ ├── codecov-white.svg
│ ├── coderabbit-logo-dark.svg
│ └── coderabbit-logo-light.svg
├── .github
├── FUNDING.yml
└── workflows
│ ├── release.yml
│ ├── crowdin.yml
│ ├── CI.yml
│ └── checkly.yml
├── lint-staged.config.js
├── postcss.config.mjs
├── commitlint.config.ts
├── next-env.d.ts
├── migrations
├── 0000_init-db.sql
└── meta
│ ├── _journal.json
│ └── 0000_snapshot.json
├── vitest-setup.ts
├── drizzle.config.ts
├── .vscode
├── extensions.json
├── tasks.json
├── launch.json
└── settings.json
├── .storybook
├── preview.ts
└── main.ts
├── tailwind.config.ts
├── vitest.config.mts
├── .gitignore
├── .coderabbit.yaml
├── crowdin.yml
├── tests
├── e2e
│ ├── I18n.e2e.ts
│ ├── Visual.e2e.ts
│ ├── Counter.e2e.ts
│ └── Sanity.check.e2e.ts
└── integration
│ └── Counter.spec.ts
├── LICENSE
├── .env
├── .env.production
├── sentry.client.config.ts
├── checkly.config.ts
├── README.md
├── tsconfig.json
├── eslint.config.mjs
├── playwright.config.ts
├── next.config.ts
└── package.json
/src/services/splitwise.service.ts:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/types/daisyui.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'daisyui';
2 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | coverage:
2 | status:
3 | patch: off
4 |
--------------------------------------------------------------------------------
/.husky/commit-msg:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | cd "$(dirname "$0")/.." && npx --no -- commitlint --edit $1
3 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/assets/images/sentry-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/sentry-dark.png
--------------------------------------------------------------------------------
/public/assets/images/crowdin-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/crowdin-dark.png
--------------------------------------------------------------------------------
/public/assets/images/crowdin-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/crowdin-white.png
--------------------------------------------------------------------------------
/public/assets/images/sentry-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/sentry-white.png
--------------------------------------------------------------------------------
/public/assets/images/sevalla-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/sevalla-dark.png
--------------------------------------------------------------------------------
/public/assets/images/sevalla-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/sevalla-light.png
--------------------------------------------------------------------------------
/public/assets/images/clerk-logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/clerk-logo-dark.png
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: ixartz
2 | custom:
3 | - 'https://nextjs-boilerplate.com/pro-saas-starter-kit'
4 | - 'https://nextlessjs.com'
5 |
--------------------------------------------------------------------------------
/lint-staged.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | '*': ['eslint --fix --no-warn-ignored'],
3 | '**/*.ts?(x)': () => 'npm run check-types',
4 | };
5 |
--------------------------------------------------------------------------------
/public/assets/images/better-stack-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/better-stack-dark.png
--------------------------------------------------------------------------------
/public/assets/images/better-stack-white.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/better-stack-white.png
--------------------------------------------------------------------------------
/public/assets/images/checkly-logo-dark.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/checkly-logo-dark.png
--------------------------------------------------------------------------------
/public/assets/images/checkly-logo-light.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/checkly-logo-light.png
--------------------------------------------------------------------------------
/public/assets/images/nextjs-starter-banner.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/nextjs-starter-banner.png
--------------------------------------------------------------------------------
/public/assets/images/nextjs-boilerplate-saas.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/nextjs-boilerplate-saas.png
--------------------------------------------------------------------------------
/public/assets/images/nextjs-boilerplate-sign-in.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/nextjs-boilerplate-sign-in.png
--------------------------------------------------------------------------------
/public/assets/images/nextjs-boilerplate-sign-up.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/HermanL02/LiveSplitBoard/HEAD/public/assets/images/nextjs-boilerplate-sign-up.png
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 | # Disable concurrent to run `check-types` after ESLint in lint-staged
3 | cd "$(dirname "$0")/.." && npx --no lint-staged --concurrent false
4 |
--------------------------------------------------------------------------------
/src/validations/CounterValidation.ts:
--------------------------------------------------------------------------------
1 | import { z } from 'zod';
2 |
3 | export const CounterValidation = z.object({
4 | increment: z.coerce.number().min(1).max(3),
5 | });
6 |
--------------------------------------------------------------------------------
/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | '@tailwindcss/postcss': {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/src/styles/global.css:
--------------------------------------------------------------------------------
1 | @import 'tailwindcss';
2 |
3 | @layer base {
4 | html {
5 | @apply bg-gray-900 text-white;
6 | }
7 |
8 | body {
9 | @apply min-h-screen;
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/commitlint.config.ts:
--------------------------------------------------------------------------------
1 | import type { UserConfig } from '@commitlint/types';
2 |
3 | const Configuration: UserConfig = {
4 | extends: ['@commitlint/config-conventional'],
5 | };
6 |
7 | export default Configuration;
8 |
--------------------------------------------------------------------------------
/src/types/global.d.ts:
--------------------------------------------------------------------------------
1 | // Use type safe message keys with `next-intl`
2 | type Messages = typeof import('../locales/en.json');
3 |
4 | // eslint-disable-next-line
5 | declare interface IntlMessages extends Messages {}
6 |
--------------------------------------------------------------------------------
/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
6 |
--------------------------------------------------------------------------------
/migrations/0000_init-db.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS "counter" (
2 | "id" serial PRIMARY KEY NOT NULL,
3 | "count" integer DEFAULT 0,
4 | "updated_at" timestamp DEFAULT now() NOT NULL,
5 | "created_at" timestamp DEFAULT now() NOT NULL
6 | );
7 |
--------------------------------------------------------------------------------
/migrations/meta/_journal.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": "7",
3 | "dialect": "postgresql",
4 | "entries": [
5 | {
6 | "idx": 0,
7 | "version": "7",
8 | "when": 1725916508373,
9 | "tag": "0000_init-db",
10 | "breakpoints": true
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/vitest-setup.ts:
--------------------------------------------------------------------------------
1 | import failOnConsole from 'vitest-fail-on-console';
2 | import '@testing-library/jest-dom/vitest';
3 |
4 | failOnConsole({
5 | shouldFailOnDebug: true,
6 | shouldFailOnError: true,
7 | shouldFailOnInfo: true,
8 | shouldFailOnLog: true,
9 | shouldFailOnWarn: true,
10 | });
11 |
--------------------------------------------------------------------------------
/drizzle.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'drizzle-kit';
2 |
3 | export default defineConfig({
4 | out: './migrations',
5 | schema: './src/models/Schema.ts',
6 | dialect: 'postgresql',
7 | dbCredentials: {
8 | url: process.env.DATABASE_URL ?? '',
9 | },
10 | verbose: true,
11 | strict: true,
12 | });
13 |
--------------------------------------------------------------------------------
/src/app/robots.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next';
2 | import { getBaseUrl } from '@/utils/Helpers';
3 |
4 | export default function robots(): MetadataRoute.Robots {
5 | return {
6 | rules: {
7 | userAgent: '*',
8 | allow: '/',
9 | },
10 | sitemap: `${getBaseUrl()}/sitemap.xml`,
11 | };
12 | }
13 |
--------------------------------------------------------------------------------
/src/utils/AppConfig.ts:
--------------------------------------------------------------------------------
1 | import type { LocalePrefixMode } from 'next-intl/routing';
2 |
3 | const localePrefix: LocalePrefixMode = 'as-needed';
4 |
5 | // FIXME: Update this configuration file based on your project information
6 | export const AppConfig = {
7 | name: 'Nextjs Starter',
8 | locales: ['en', 'fr'],
9 | defaultLocale: 'en',
10 | localePrefix,
11 | };
12 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": [
3 | "dbaeumer.vscode-eslint",
4 | "mikestead.dotenv",
5 | "csstools.postcss",
6 | "bradlc.vscode-tailwindcss",
7 | "vitest.explorer",
8 | "humao.rest-client",
9 | "yoavbls.pretty-ts-errors",
10 | "ms-playwright.playwright",
11 | "github.vscode-github-actions",
12 | "lokalise.i18n-ally"
13 | ]
14 | }
15 |
--------------------------------------------------------------------------------
/src/app/sitemap.ts:
--------------------------------------------------------------------------------
1 | import type { MetadataRoute } from 'next';
2 | import { getBaseUrl } from '@/utils/Helpers';
3 |
4 | export default function sitemap(): MetadataRoute.Sitemap {
5 | return [
6 | {
7 | url: `${getBaseUrl()}/`,
8 | lastModified: new Date(),
9 | changeFrequency: 'daily',
10 | priority: 0.7,
11 | },
12 | // Add more URLs here
13 | ];
14 | }
15 |
--------------------------------------------------------------------------------
/src/components/DemoBanner.tsx:
--------------------------------------------------------------------------------
1 | import Link from 'next/link';
2 |
3 | export const DemoBanner = () => (
4 |
5 | Live Demo of Next.js Boilerplate -
6 | {' '}
7 | Explore the Authentication
8 |
9 | );
10 |
--------------------------------------------------------------------------------
/.storybook/preview.ts:
--------------------------------------------------------------------------------
1 | import type { Preview } from '@storybook/react';
2 | import '../src/styles/global.css';
3 |
4 | const preview: Preview = {
5 | parameters: {
6 | controls: {
7 | matchers: {
8 | color: /(background|color)$/i,
9 | date: /Date$/i,
10 | },
11 | },
12 | nextjs: {
13 | appDirectory: true,
14 | },
15 | },
16 | };
17 |
18 | export default preview;
19 |
--------------------------------------------------------------------------------
/src/components/DemoBadge.tsx:
--------------------------------------------------------------------------------
1 | export const DemoBadge = () => (
2 |
12 | );
13 |
--------------------------------------------------------------------------------
/src/libs/i18nNavigation.ts:
--------------------------------------------------------------------------------
1 | import { AppConfig } from '@/utils/AppConfig';
2 | import { createNavigation } from 'next-intl/navigation';
3 | import { defineRouting } from 'next-intl/routing';
4 |
5 | export const routing = defineRouting({
6 | locales: AppConfig.locales,
7 | localePrefix: AppConfig.localePrefix,
8 | defaultLocale: AppConfig.defaultLocale,
9 | });
10 |
11 | export const { usePathname, useRouter } = createNavigation(routing);
12 |
--------------------------------------------------------------------------------
/src/app/[locale]/(auth)/(center)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { setRequestLocale } from 'next-intl/server';
2 |
3 | export default async function CenteredLayout(props: {
4 | children: React.ReactNode;
5 | params: Promise<{ locale: string }>;
6 | }) {
7 | const { locale } = await props.params;
8 | setRequestLocale(locale);
9 |
10 | return (
11 |
12 | {props.children}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/lib/mongodb/init.ts:
--------------------------------------------------------------------------------
1 | import { connectDB } from '@/lib/mongodb/database';
2 |
3 | let isConnected = false;
4 |
5 | export const initializeMongoDB = async () => {
6 | if (isConnected) {
7 | console.warn('MongoDB is already connected');
8 | return;
9 | }
10 |
11 | try {
12 | await connectDB();
13 | isConnected = true;
14 | console.warn('MongoDB initialized successfully');
15 | } catch (error) {
16 | console.error('Failed to initialize MongoDB:', error);
17 | throw error;
18 | }
19 | };
20 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "label": "Project wide type checking with TypeScript",
8 | "type": "npm",
9 | "script": "check-types",
10 | "problemMatcher": ["$tsc"],
11 | "group": {
12 | "kind": "build",
13 | "isDefault": true
14 | },
15 | "presentation": {
16 | "clear": true,
17 | "reveal": "never"
18 | }
19 | }
20 | ]
21 | }
22 |
--------------------------------------------------------------------------------
/.storybook/main.ts:
--------------------------------------------------------------------------------
1 | import type { StorybookConfig } from '@storybook/nextjs';
2 |
3 | const config: StorybookConfig = {
4 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
5 | addons: [
6 | '@storybook/addon-onboarding',
7 | '@storybook/addon-links',
8 | '@storybook/addon-essentials',
9 | '@storybook/addon-interactions',
10 | ],
11 | framework: {
12 | name: '@storybook/nextjs',
13 | options: {},
14 | },
15 | staticDirs: ['../public'],
16 | core: {
17 | disableTelemetry: true,
18 | },
19 | };
20 |
21 | export default config;
22 |
--------------------------------------------------------------------------------
/src/app/[locale]/(auth)/dashboard/page.tsx:
--------------------------------------------------------------------------------
1 | import { Hello } from '@/components/Hello';
2 | import { getTranslations } from 'next-intl/server';
3 |
4 | export async function generateMetadata(props: {
5 | params: Promise<{ locale: string }>;
6 | }) {
7 | const { locale } = await props.params;
8 | const t = await getTranslations({
9 | locale,
10 | namespace: 'Dashboard',
11 | });
12 |
13 | return {
14 | title: t('meta_title'),
15 | };
16 | }
17 |
18 | export default function Dashboard() {
19 | return (
20 |
21 |
22 |
23 | );
24 | }
25 |
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Use IntelliSense to learn about possible attributes.
3 | // Hover to view descriptions of existing attributes.
4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 | {
8 | "name": "Next.js: debug full stack",
9 | "type": "node-terminal",
10 | "request": "launch",
11 | "command": "npm run dev",
12 | "serverReadyAction": {
13 | "pattern": "- Local:.+(https?://.+)",
14 | "uriFormat": "%s",
15 | "action": "debugWithChrome"
16 | }
17 | }
18 | ]
19 | }
20 |
--------------------------------------------------------------------------------
/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from 'tailwindcss';
2 | import daisyui from 'daisyui';
3 |
4 | const config: Config = {
5 | content: [
6 | './src/pages/**/*.{js,ts,jsx,tsx,mdx}',
7 | './src/components/**/*.{js,ts,jsx,tsx,mdx}',
8 | './src/app/**/*.{js,ts,jsx,tsx,mdx}',
9 | ],
10 | darkMode: 'class',
11 | theme: {
12 | extend: {
13 | backgroundColor: {
14 | DEFAULT: '#000000',
15 | dark: {
16 | primary: '#1a1a1a',
17 | secondary: '#2d2d2d',
18 | tertiary: '#3d3d3d',
19 | },
20 | },
21 | },
22 | },
23 | plugins: [daisyui],
24 | };
25 |
26 | export default config;
27 |
--------------------------------------------------------------------------------
/src/utils/Helpers.test.ts:
--------------------------------------------------------------------------------
1 | import { routing } from '@/libs/i18nNavigation';
2 | import { getI18nPath } from './Helpers';
3 |
4 | describe('Helpers', () => {
5 | describe('getI18nPath function', () => {
6 | it('should not change the path for default language', () => {
7 | const url = '/random-url';
8 | const locale = routing.defaultLocale;
9 |
10 | expect(getI18nPath(url, locale)).toBe(url);
11 | });
12 |
13 | it('should prepend the locale to the path for non-default language', () => {
14 | const url = '/random-url';
15 | const locale = 'fr';
16 |
17 | expect(getI18nPath(url, locale)).toMatch(/^\/fr/);
18 | });
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/libs/Arcjet.ts:
--------------------------------------------------------------------------------
1 | import arcjet, { shield } from '@arcjet/next';
2 |
3 | // Create a base Arcjet instance which can be imported and extended in each route.
4 | export default arcjet({
5 | // Get your site key from https://launch.arcjet.com/Q6eLbRE
6 | // Use `process.env` instead of Env to reduce bundle size in middleware
7 | key: process.env.ARCJET_KEY ?? '',
8 | // Identify the user by their IP address
9 | characteristics: ['ip.src'],
10 | rules: [
11 | // Protect against common attacks with Arcjet Shield
12 | shield({
13 | mode: 'LIVE', // will block requests. Use "DRY_RUN" to log only
14 | }),
15 | // Other rules are added in different routes
16 | ],
17 | });
18 |
--------------------------------------------------------------------------------
/src/libs/Logger.ts:
--------------------------------------------------------------------------------
1 | import type { DestinationStream } from 'pino';
2 | import logtail from '@logtail/pino';
3 | import pino from 'pino';
4 | import pretty from 'pino-pretty';
5 | import { Env } from './Env';
6 |
7 | let stream: DestinationStream;
8 |
9 | if (Env.LOGTAIL_SOURCE_TOKEN) {
10 | stream = pino.multistream([
11 | await logtail({
12 | sourceToken: Env.LOGTAIL_SOURCE_TOKEN,
13 | options: {
14 | sendLogsToBetterStack: true,
15 | },
16 | }),
17 | {
18 | stream: pretty(), // Prints logs to the console
19 | },
20 | ]);
21 | } else {
22 | stream = pretty({
23 | colorize: true,
24 | });
25 | }
26 |
27 | export const logger = pino({ base: undefined }, stream);
28 |
--------------------------------------------------------------------------------
/src/utils/Helpers.ts:
--------------------------------------------------------------------------------
1 | import { routing } from '@/libs/i18nNavigation';
2 |
3 | export const getBaseUrl = () => {
4 | if (process.env.NEXT_PUBLIC_APP_URL) {
5 | return process.env.NEXT_PUBLIC_APP_URL;
6 | }
7 |
8 | if (
9 | process.env.VERCEL_ENV === 'production'
10 | && process.env.VERCEL_PROJECT_PRODUCTION_URL
11 | ) {
12 | return `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`;
13 | }
14 |
15 | if (process.env.VERCEL_URL) {
16 | return `https://${process.env.VERCEL_URL}`;
17 | }
18 |
19 | return 'http://localhost:3000';
20 | };
21 |
22 | export const getI18nPath = (url: string, locale: string) => {
23 | if (locale === routing.defaultLocale) {
24 | return url;
25 | }
26 |
27 | return `/${locale}${url}`;
28 | };
29 |
--------------------------------------------------------------------------------
/vitest.config.mts:
--------------------------------------------------------------------------------
1 | import react from '@vitejs/plugin-react';
2 | import { loadEnv } from 'vite';
3 | import tsconfigPaths from 'vite-tsconfig-paths';
4 | import { defineConfig } from 'vitest/config';
5 |
6 | export default defineConfig({
7 | plugins: [react(), tsconfigPaths()],
8 | test: {
9 | globals: true, // This is needed by @testing-library to be cleaned up after each test
10 | include: ['src/**/*.test.{js,jsx,ts,tsx}'],
11 | coverage: {
12 | include: ['src/**/*'],
13 | exclude: ['src/**/*.stories.{js,jsx,ts,tsx}', '**/*.d.ts'],
14 | },
15 | environmentMatchGlobs: [
16 | ['**/*.test.tsx', 'jsdom'],
17 | ['src/hooks/**/*.test.ts', 'jsdom'],
18 | ],
19 | setupFiles: ['./vitest-setup.ts'],
20 | env: loadEnv('', process.cwd(), ''),
21 | },
22 | });
23 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # Database
9 | *.db
10 |
11 | # testing
12 | /coverage
13 |
14 | # storybook
15 | storybook-static
16 | *storybook.log
17 |
18 | # playwright
19 | /test-results/
20 | /playwright-report/
21 | /playwright/.cache/
22 |
23 | # next.js
24 | /.next
25 | /out
26 |
27 | # cache
28 | .swc/
29 |
30 | # production
31 | /build
32 |
33 | # misc
34 | .DS_Store
35 | *.pem
36 | Thumbs.db
37 |
38 | # debug
39 | npm-debug.log*
40 | pnpm-debug.log*
41 | yarn-debug.log*
42 | yarn-error.log*
43 |
44 | # local env files
45 | .env*.local
46 |
47 | # Sentry Config File
48 | .env.sentry-build-plugin
49 |
50 | # local folder
51 | local
52 |
53 | # vercel
54 | .vercel
55 |
--------------------------------------------------------------------------------
/.coderabbit.yaml:
--------------------------------------------------------------------------------
1 | # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json
2 |
3 | # CodeRabbit is an AI-powered code reviewer that cuts review time and bugs in half
4 |
5 | language: en-US
6 | early_access: false
7 | reviews:
8 | profile: chill
9 | request_changes_workflow: false
10 | high_level_summary: true
11 | poem: true
12 | review_status: true
13 | collapse_walkthrough: false
14 | path_instructions:
15 | - path: '**/*.{ts,tsx}'
16 | instructions:
17 | 'Review the Typescript and React code for conformity with best practices in React, and Typescript. Highlight any deviations.'
18 | auto_review:
19 | enabled: true
20 | ignore_title_keywords:
21 | - WIP
22 | - DO NOT MERGE
23 | - DRAFT
24 | drafts: false
25 | chat:
26 | auto_reply: true
27 |
--------------------------------------------------------------------------------
/crowdin.yml:
--------------------------------------------------------------------------------
1 | #
2 | # Your Crowdin credentials
3 | #
4 | # No need modify CROWDIN_PROJECT_ID and CROWDIN_PERSONAL_TOKEN, you can set them in GitHub Actions secrets
5 | project_id_env: CROWDIN_PROJECT_ID
6 | api_token_env: CROWDIN_PERSONAL_TOKEN
7 | base_path: .
8 | base_url: 'https://api.crowdin.com' # https://{organization-name}.crowdin.com for Crowdin Enterprise
9 |
10 | #
11 | # Choose file structure in Crowdin
12 | # e.g. true or false
13 | #
14 | preserve_hierarchy: true
15 |
16 | #
17 | # Files configuration
18 | #
19 | files:
20 | - source: /src/locales/en.json
21 |
22 | #
23 | # Where translations will be placed
24 | # e.g. "/resources/%two_letters_code%/%original_file_name%"
25 | #
26 | translation: '/src/locales/%two_letters_code%.json'
27 |
28 | #
29 | # File type
30 | # e.g. "json"
31 | #
32 | type: json
33 |
--------------------------------------------------------------------------------
/src/app/global-error.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { routing } from '@/libs/i18nNavigation';
4 | import * as Sentry from '@sentry/nextjs';
5 | import NextError from 'next/error';
6 | import { useEffect } from 'react';
7 |
8 | export default function GlobalError(props: {
9 | error: Error & { digest?: string };
10 | }) {
11 | useEffect(() => {
12 | Sentry.captureException(props.error);
13 | }, [props.error]);
14 |
15 | return (
16 |
17 |
18 | {/* `NextError` is the default Next.js error page component. Its type
19 | definition requires a `statusCode` prop. However, since the App Router
20 | does not expose status codes for errors, we simply pass 0 to render a
21 | generic error message. */}
22 |
23 |
24 |
25 | );
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/[locale]/(auth)/(center)/sign-in/[[...sign-in]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getI18nPath } from '@/utils/Helpers';
2 | import { SignIn } from '@clerk/nextjs';
3 | import { getTranslations, setRequestLocale } from 'next-intl/server';
4 |
5 | type ISignInPageProps = {
6 | params: Promise<{ locale: string }>;
7 | };
8 |
9 | export async function generateMetadata(props: ISignInPageProps) {
10 | const { locale } = await props.params;
11 | const t = await getTranslations({
12 | locale,
13 | namespace: 'SignIn',
14 | });
15 |
16 | return {
17 | title: t('meta_title'),
18 | description: t('meta_description'),
19 | };
20 | }
21 |
22 | export default async function SignInPage(props: ISignInPageProps) {
23 | const { locale } = await props.params;
24 | setRequestLocale(locale);
25 |
26 | return (
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/app/[locale]/(auth)/(center)/sign-up/[[...sign-up]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getI18nPath } from '@/utils/Helpers';
2 | import { SignUp } from '@clerk/nextjs';
3 | import { getTranslations, setRequestLocale } from 'next-intl/server';
4 |
5 | type ISignUpPageProps = {
6 | params: Promise<{ locale: string }>;
7 | };
8 |
9 | export async function generateMetadata(props: ISignUpPageProps) {
10 | const { locale } = await props.params;
11 | const t = await getTranslations({
12 | locale,
13 | namespace: 'SignUp',
14 | });
15 |
16 | return {
17 | title: t('meta_title'),
18 | description: t('meta_description'),
19 | };
20 | }
21 |
22 | export default async function SignUpPage(props: ISignUpPageProps) {
23 | const { locale } = await props.params;
24 | setRequestLocale(locale);
25 |
26 | return (
27 |
28 | );
29 | };
30 |
--------------------------------------------------------------------------------
/src/components/Hello.tsx:
--------------------------------------------------------------------------------
1 | import { currentUser } from '@clerk/nextjs/server';
2 | import { getTranslations } from 'next-intl/server';
3 | import { Sponsors } from './Sponsors';
4 |
5 | export const Hello = async () => {
6 | const t = await getTranslations('Dashboard');
7 | const user = await currentUser();
8 |
9 | return (
10 | <>
11 |
12 | {`👋 `}
13 | {t('hello_message', { email: user?.emailAddresses[0]?.emailAddress })}
14 |
15 |
16 | {t.rich('alternative_message', {
17 | url: () => (
18 |
22 | Next.js Boilerplate SaaS
23 |
24 | ),
25 | })}
26 |
27 |
28 | >
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/app/[locale]/api/splitwise/groups/info/route.ts:
--------------------------------------------------------------------------------
1 | import { NextResponse } from 'next/server';
2 |
3 | export async function GET() {
4 | try {
5 | const response = await fetch(`https://secure.splitwise.com/api/v3.0/get_groups`, {
6 | headers: {
7 | 'Authorization': `Bearer ${process.env.SPLITWISE_API_KEY}`,
8 | 'Content-Type': 'application/json',
9 | },
10 | });
11 |
12 | if (!response.ok) {
13 | throw new Error(`Splitwise API Error: ${response.status}`);
14 | }
15 |
16 | const data = await response.json();
17 | return NextResponse.json(data, {
18 | headers: {
19 | 'Cache-Control': 'public, max-age=1800, s-maxage=1800',
20 | },
21 | });
22 | } catch (error) {
23 | console.error('Splitwise API Error:', error);
24 | return NextResponse.json(
25 | { error: 'Get Splitwise Data Error' },
26 | { status: 500 },
27 | );
28 | }
29 | }
30 |
--------------------------------------------------------------------------------
/src/app/[locale]/(auth)/dashboard/user-profile/[[...user-profile]]/page.tsx:
--------------------------------------------------------------------------------
1 | import { getI18nPath } from '@/utils/Helpers';
2 | import { UserProfile } from '@clerk/nextjs';
3 | import { getTranslations, setRequestLocale } from 'next-intl/server';
4 |
5 | type IUserProfilePageProps = {
6 | params: Promise<{ locale: string }>;
7 | };
8 |
9 | export async function generateMetadata(props: IUserProfilePageProps) {
10 | const { locale } = await props.params;
11 | const t = await getTranslations({
12 | locale,
13 | namespace: 'UserProfile',
14 | });
15 |
16 | return {
17 | title: t('meta_title'),
18 | };
19 | }
20 |
21 | export default async function UserProfilePage(props: IUserProfilePageProps) {
22 | const { locale } = await props.params;
23 | setRequestLocale(locale);
24 |
25 | return (
26 |
27 |
30 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/src/components/analytics/PostHogProvider.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { Env } from '@/libs/Env';
4 | import posthog from 'posthog-js';
5 | import { PostHogProvider as PHProvider } from 'posthog-js/react';
6 | import { useEffect } from 'react';
7 | import { SuspendedPostHogPageView } from './PostHogPageView';
8 |
9 | export const PostHogProvider = (props: { children: React.ReactNode }) => {
10 | useEffect(() => {
11 | if (Env.NEXT_PUBLIC_POSTHOG_KEY) {
12 | posthog.init(Env.NEXT_PUBLIC_POSTHOG_KEY, {
13 | api_host: Env.NEXT_PUBLIC_POSTHOG_HOST,
14 | capture_pageview: false, // Disable automatic pageview capture, as we capture manually
15 | capture_pageleave: true, // Enable pageleave capture
16 | });
17 | }
18 | }, []);
19 |
20 | if (!Env.NEXT_PUBLIC_POSTHOG_KEY) {
21 | return props.children;
22 | }
23 |
24 | return (
25 |
26 |
27 | {props.children}
28 |
29 | );
30 | };
31 |
--------------------------------------------------------------------------------
/src/components/LocaleSwitcher.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import type { ChangeEventHandler } from 'react';
4 | import { routing, usePathname } from '@/libs/i18nNavigation';
5 | import { useLocale } from 'next-intl';
6 | import { useRouter } from 'next/navigation';
7 |
8 | export const LocaleSwitcher = () => {
9 | const router = useRouter();
10 | const pathname = usePathname();
11 | const locale = useLocale();
12 |
13 | const handleChange: ChangeEventHandler = (event) => {
14 | router.push(`/${event.target.value}${pathname}`);
15 | router.refresh();
16 | };
17 |
18 | return (
19 |
31 | );
32 | };
33 |
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | workflow_run:
5 | workflows: [CI]
6 | types:
7 | - completed
8 | branches:
9 | - main
10 |
11 | jobs:
12 | release:
13 | strategy:
14 | matrix:
15 | node-version: [20.x]
16 |
17 | name: Create a new release
18 | runs-on: ubuntu-latest
19 |
20 | permissions:
21 | contents: write # to be able to publish a GitHub release
22 | issues: write # to be able to comment on released issues
23 | pull-requests: write # to be able to comment on released pull requests
24 |
25 | steps:
26 | - uses: actions/checkout@v4
27 | with:
28 | fetch-depth: 0
29 | - name: Use Node.js ${{ matrix.node-version }}
30 | uses: actions/setup-node@v4
31 | with:
32 | node-version: ${{ matrix.node-version }}
33 | cache: npm
34 | - run: HUSKY=0 npm ci
35 |
36 | - name: Release
37 | env:
38 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
39 | run: npx semantic-release
40 |
--------------------------------------------------------------------------------
/src/lib/discord/discord.ts:
--------------------------------------------------------------------------------
1 | import { logger } from '@/libs/Logger';
2 |
3 | async function sendMessage(message: string) {
4 | const webhookUrl = process.env.DISCORD_WEBHOOK_URL;
5 | if (!webhookUrl) {
6 | throw new Error('DISCORD_WEBHOOK_URL is not set');
7 | }
8 |
9 | if (!message || message.trim() === '') {
10 | logger.error('Empty message provided to sendMessage, skipping');
11 | return;
12 | }
13 |
14 | try {
15 | const response = await fetch(webhookUrl, {
16 | method: 'POST',
17 | headers: {
18 | 'Content-Type': 'application/json',
19 | },
20 | body: JSON.stringify({ content: message }),
21 | });
22 |
23 | if (!response.ok) {
24 | const errorText = await response.text();
25 | throw new Error(`Discord webhook error: ${response.status} - ${errorText}`);
26 | }
27 | logger.info('Message sent to Discord');
28 | return true;
29 | } catch (error) {
30 | logger.error('Error sending message to Discord:', error);
31 | throw error;
32 | }
33 | }
34 |
35 | export default sendMessage;
36 |
--------------------------------------------------------------------------------
/src/lib/mongodb/database.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose';
2 |
3 | const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/expense-tracker';
4 |
5 | export const connectDB = async () => {
6 | try {
7 | if (!mongoose.connections.length || (mongoose.connections[0] && mongoose.connections[0].readyState === 0)) {
8 | await mongoose.connect(MONGODB_URI);
9 | console.warn('MongoDB connected successfully');
10 | }
11 | } catch (error) {
12 | console.error('MongoDB connection error:', error);
13 | throw error;
14 | }
15 | };
16 |
17 | export const disconnectDB = async () => {
18 | try {
19 | await mongoose.disconnect();
20 | console.warn('MongoDB disconnected successfully');
21 | } catch (error) {
22 | console.error('MongoDB disconnection error:', error);
23 | throw error;
24 | }
25 | };
26 |
27 | export const getDB = () => {
28 | if (!mongoose.connection.readyState) {
29 | throw new Error('MongoDB is not connected');
30 | }
31 | return mongoose.connection.db;
32 | };
33 |
34 | // 移除事件监听器,因为在Edge Runtime中不可靠
35 | // 使用isConnected标志来跟踪连接状态
36 |
--------------------------------------------------------------------------------
/tests/e2e/I18n.e2e.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | test.describe('I18n', () => {
4 | test.describe('Language Switching', () => {
5 | test('should switch language from English to French using dropdown and verify text on the homepage', async ({ page }) => {
6 | await page.goto('/');
7 |
8 | await expect(
9 | page.getByRole('heading', { name: 'Boilerplate Code for Your Next.js Project with Tailwind CSS' }),
10 | ).toBeVisible();
11 |
12 | await page.getByLabel('lang-switcher').selectOption('fr');
13 |
14 | await expect(
15 | page.getByRole('heading', { name: 'Code de démarrage pour Next.js avec Tailwind CSS' }),
16 | ).toBeVisible();
17 | });
18 |
19 | test('should switch language from English to French using URL and verify text on the sign-in page', async ({ page }) => {
20 | await page.goto('/sign-in');
21 |
22 | await expect(page.getByText('Email address')).toBeVisible();
23 |
24 | await page.goto('/fr/sign-in');
25 |
26 | await expect(page.getByText('Adresse e-mail')).toBeVisible();
27 | });
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Remi W.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components/analytics/PostHogPageView.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { usePathname, useSearchParams } from 'next/navigation';
4 | import { usePostHog } from 'posthog-js/react';
5 | import { Suspense, useEffect } from 'react';
6 |
7 | const PostHogPageView = () => {
8 | const pathname = usePathname();
9 | const searchParams = useSearchParams();
10 | const posthog = usePostHog();
11 |
12 | // Track pageviews
13 | useEffect(() => {
14 | if (pathname && posthog) {
15 | let url = window.origin + pathname;
16 | if (searchParams.toString()) {
17 | url = `${url}?${searchParams.toString()}`;
18 | }
19 |
20 | posthog.capture('$pageview', { $current_url: url });
21 | }
22 | }, [pathname, searchParams, posthog]);
23 |
24 | return null;
25 | };
26 |
27 | // Wrap this in Suspense to avoid the `useSearchParams` usage above
28 | // from de-opting the whole app into client-side rendering
29 | // See: https://nextjs.org/docs/messages/deopted-into-client-rendering
30 | export const SuspendedPostHogPageView = () => {
31 | return (
32 |
33 |
34 |
35 | );
36 | };
37 |
--------------------------------------------------------------------------------
/src/libs/i18n.ts:
--------------------------------------------------------------------------------
1 | import { getRequestConfig } from 'next-intl/server';
2 | import { routing } from './i18nNavigation';
3 |
4 | // NextJS Boilerplate uses Crowdin as the localization software.
5 | // As a developer, you only need to take care of the English (or another default language) version.
6 | // Other languages are automatically generated and handled by Crowdin.
7 |
8 | // The localisation files are synced with Crowdin using GitHub Actions.
9 | // By default, there are 3 ways to sync the message files:
10 | // 1. Automatically sync on push to the `main` branch
11 | // 2. Run manually the workflow on GitHub Actions
12 | // 3. Every 24 hours at 5am, the workflow will run automatically
13 |
14 | // Using internationalization in Server Components
15 | export default getRequestConfig(async ({ requestLocale }) => {
16 | // This typically corresponds to the `[locale]` segment
17 | let locale = await requestLocale;
18 |
19 | // Validate that the incoming `locale` parameter is valid
20 | if (!locale || !routing.locales.includes(locale)) {
21 | locale = routing.defaultLocale;
22 | }
23 |
24 | return {
25 | locale,
26 | messages: (await import(`../locales/${locale}.json`)).default,
27 | };
28 | });
29 |
--------------------------------------------------------------------------------
/.github/workflows/crowdin.yml:
--------------------------------------------------------------------------------
1 | name: Crowdin Action
2 |
3 | on:
4 | push:
5 | branches: [main] # Run on push to the main branch
6 | schedule:
7 | - cron: '0 5 * * *' # Run every day at 5am
8 | workflow_dispatch: # Run manually
9 |
10 | jobs:
11 | synchronize-with-crowdin:
12 | name: Synchronize with Crowdin
13 | runs-on: ubuntu-latest
14 |
15 | steps:
16 | - uses: actions/checkout@v4
17 |
18 | - name: crowdin action
19 | uses: crowdin/github-action@v2
20 | with:
21 | upload_sources: true
22 | upload_translations: true
23 | download_translations: true
24 | localization_branch_name: l10n_crowdin_translations
25 | create_pull_request: true
26 | pull_request_title: New Crowdin Translations
27 | pull_request_body: 'New Crowdin translations by [Crowdin GH Action](https://github.com/crowdin/github-action)'
28 | pull_request_base_branch_name: main
29 | commit_message: 'chore: new Crowdin translations by GitHub Action'
30 | env:
31 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
32 | CROWDIN_PROJECT_ID: ${{ secrets.CROWDIN_PROJECT_ID }}
33 | CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
34 |
--------------------------------------------------------------------------------
/src/app/[locale]/(auth)/layout.tsx:
--------------------------------------------------------------------------------
1 | import { routing } from '@/libs/i18nNavigation';
2 | import { enUS, frFR } from '@clerk/localizations';
3 | import { ClerkProvider } from '@clerk/nextjs';
4 | import { setRequestLocale } from 'next-intl/server';
5 |
6 | export default async function AuthLayout(props: {
7 | children: React.ReactNode;
8 | params: Promise<{ locale: string }>;
9 | }) {
10 | const { locale } = await props.params;
11 | setRequestLocale(locale);
12 | let clerkLocale = enUS;
13 | let signInUrl = '/sign-in';
14 | let signUpUrl = '/sign-up';
15 | let dashboardUrl = '/dashboard';
16 | let afterSignOutUrl = '/';
17 |
18 | if (locale === 'fr') {
19 | clerkLocale = frFR;
20 | }
21 |
22 | if (locale !== routing.defaultLocale) {
23 | signInUrl = `/${locale}${signInUrl}`;
24 | signUpUrl = `/${locale}${signUpUrl}`;
25 | dashboardUrl = `/${locale}${dashboardUrl}`;
26 | afterSignOutUrl = `/${locale}${afterSignOutUrl}`;
27 | }
28 |
29 | return (
30 |
38 | {props.children}
39 |
40 | );
41 | }
42 |
--------------------------------------------------------------------------------
/src/instrumentation.ts:
--------------------------------------------------------------------------------
1 | import * as Sentry from '@sentry/nextjs';
2 |
3 | export const onRequestError = Sentry.captureRequestError;
4 |
5 | export async function register() {
6 | if (process.env.NEXT_RUNTIME === 'nodejs') {
7 | // Node.js Sentry configuration
8 | Sentry.init({
9 | // Sentry DSN
10 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
11 |
12 | // Enable Spotlight in development
13 | spotlight: process.env.NODE_ENV === 'development',
14 |
15 | // Adjust this value in production, or use tracesSampler for greater control
16 | tracesSampleRate: 1,
17 |
18 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
19 | debug: false,
20 | });
21 | }
22 |
23 | if (process.env.NEXT_RUNTIME === 'edge') {
24 | // Edge Sentry configuration
25 | Sentry.init({
26 | // Sentry DSN
27 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
28 |
29 | // Enable Spotlight in development
30 | spotlight: process.env.NODE_ENV === 'development',
31 |
32 | // Adjust this value in production, or use tracesSampler for greater control
33 | tracesSampleRate: 1,
34 |
35 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
36 | debug: false,
37 | });
38 | }
39 | }
40 |
--------------------------------------------------------------------------------
/migrations/meta/0000_snapshot.json:
--------------------------------------------------------------------------------
1 | {
2 | "id": "fd746382-d1ee-40c4-a173-db4142ca9fef",
3 | "prevId": "00000000-0000-0000-0000-000000000000",
4 | "version": "7",
5 | "dialect": "postgresql",
6 | "tables": {
7 | "public.counter": {
8 | "name": "counter",
9 | "schema": "",
10 | "columns": {
11 | "id": {
12 | "name": "id",
13 | "type": "serial",
14 | "primaryKey": true,
15 | "notNull": true
16 | },
17 | "count": {
18 | "name": "count",
19 | "type": "integer",
20 | "primaryKey": false,
21 | "notNull": false,
22 | "default": 0
23 | },
24 | "updated_at": {
25 | "name": "updated_at",
26 | "type": "timestamp",
27 | "primaryKey": false,
28 | "notNull": true,
29 | "default": "now()"
30 | },
31 | "created_at": {
32 | "name": "created_at",
33 | "type": "timestamp",
34 | "primaryKey": false,
35 | "notNull": true,
36 | "default": "now()"
37 | }
38 | },
39 | "indexes": {},
40 | "foreignKeys": {},
41 | "compositePrimaryKeys": {},
42 | "uniqueConstraints": {}
43 | }
44 | },
45 | "enums": {},
46 | "schemas": {},
47 | "sequences": {},
48 | "_meta": {
49 | "columns": {},
50 | "schemas": {},
51 | "tables": {}
52 | }
53 | }
--------------------------------------------------------------------------------
/.env:
--------------------------------------------------------------------------------
1 | # FIXME: Configure environment variables for your project
2 | CONSUMER_KEY=
3 | CONSUMER_SECRET=
4 | REQUEST_TOKEN_URL=
5 | ACCESS_TOKEN_URL=
6 | AUTHORIZE_URL=
7 | TOKEN_URL=
8 | SPLITWISE_API_KEY=
9 | DISCORD_WEBHOOK_URL=
10 | # SPLITWISE_GROUP_ID=79865654
11 | MONGODB_URI=
12 | # If you need to build a SaaS application with Stripe subscription payment with checkout page, customer portal, webhook, etc.
13 | # You can check out the Next.js Boilerplate SaaS: https://nextjs-boilerplate.com/pro-saas-starter-kit
14 |
15 |
16 | # Clerk authentication
17 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_cmVsYXhlZC10dXJrZXktNjcuY2xlcmsuYWNjb3VudHMuZGV2JA
18 |
19 | NEXT_PUBLIC_CLERK_SIGN_IN_URL=/sign-in
20 |
21 | # Sentry
22 | # Disable Sentry warning with TurboPack
23 | SENTRY_SUPPRESS_TURBOPACK_WARNING=1
24 |
25 | # PostHog
26 | NEXT_PUBLIC_POSTHOG_KEY=
27 | NEXT_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
28 |
29 | ######## [BEGIN] SENSITIVE DATA ######## For security reason, don't update the following variables (secret key) directly in this file.
30 | ######## Please create a new file named `.env.local`, all environment files ending with `.local` won't be tracked by Git.
31 | ######## After creating the file, you can add the following variables.
32 | # Arcjet security
33 | # Get your key from https://launch.arcjet.com/Q6eLbRE
34 | # ARCJET_KEY=
35 |
36 | # Clerk authentication
37 | CLERK_SECRET_KEY=sk_test_
38 | ######## [END] SENSITIVE DATA
39 |
40 |
--------------------------------------------------------------------------------
/.env.production:
--------------------------------------------------------------------------------
1 | # FIXME: Configure environment variables for production
2 | CONSUMER_KEY=
3 | CONSUMER_SECRET=
4 | REQUEST_TOKEN_URL=
5 | ACCESS_TOKEN_URL=
6 | AUTHORIZE_URL=
7 | TOKEN_URL=
8 | SPLITWISE_API_KEY=
9 | DISCORD_WEBHOOK_URL=
10 | # SPLITWISE_GROUP_ID=79865654
11 | MONGODB_URI=
12 | # If you need to build a SaaS application with Stripe subscription payment with checkout page, customer portal, webhook, etc.
13 | # You can check out the Next.js Boilerplate SaaS: https://nextjs-boilerplate.com/pro-saas-starter-kit
14 |
15 | # Hosting
16 | # Replace by your domain name
17 | # NEXT_PUBLIC_APP_URL=https://example.com
18 |
19 | # Sentry DSN
20 | NEXT_PUBLIC_SENTRY_DSN=
21 |
22 | ######## [BEGIN] SENSITIVE DATA ######## For security reason, don't update the following variables (secret key) directly in this file.
23 | ######## Please create a new file named `.env.production.local`, all environment files ending with `.local` won't be tracked by Git.
24 | ######## After creating the file, you can add the following variables.
25 | # Arcjet security
26 | # Get your key from https://launch.arcjet.com/Q6eLbRE
27 | # ARCJET_KEY=
28 |
29 | # Database
30 | # Using an incorrect DATABASE_URL value, Next.js build will timeout and you will get the following error: "because it took more than 60 seconds"
31 | # DATABASE_URL=postgresql://postgres@localhost:5432/postgres
32 |
33 | # Error monitoring
34 | # SENTRY_AUTH_TOKEN=
35 |
36 | # Logging ingestion
37 | # LOGTAIL_SOURCE_TOKEN=
38 | ######## [END] SENSITIVE DATA
39 |
--------------------------------------------------------------------------------
/src/libs/Env.ts:
--------------------------------------------------------------------------------
1 | import { createEnv } from '@t3-oss/env-nextjs';
2 | import { z } from 'zod';
3 |
4 | export const Env = createEnv({
5 | server: {
6 | ARCJET_KEY: z.string().startsWith('ajkey_').optional(),
7 | CLERK_SECRET_KEY: z.string().min(1),
8 | DATABASE_URL: z.string().optional(),
9 | LOGTAIL_SOURCE_TOKEN: z.string().optional(),
10 | },
11 | client: {
12 | NEXT_PUBLIC_APP_URL: z.string().optional(),
13 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: z.string().min(1),
14 | NEXT_PUBLIC_CLERK_SIGN_IN_URL: z.string().min(1),
15 | NEXT_PUBLIC_POSTHOG_KEY: z.string().optional(),
16 | NEXT_PUBLIC_POSTHOG_HOST: z.string().optional(),
17 | },
18 | shared: {
19 | NODE_ENV: z.enum(['test', 'development', 'production']).optional(),
20 | },
21 | // You need to destructure all the keys manually
22 | runtimeEnv: {
23 | ARCJET_KEY: process.env.ARCJET_KEY,
24 | CLERK_SECRET_KEY: process.env.CLERK_SECRET_KEY,
25 | DATABASE_URL: process.env.DATABASE_URL,
26 | LOGTAIL_SOURCE_TOKEN: process.env.LOGTAIL_SOURCE_TOKEN,
27 | NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
28 | NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY:
29 | process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
30 | NEXT_PUBLIC_CLERK_SIGN_IN_URL: process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL,
31 | NODE_ENV: process.env.NODE_ENV,
32 | NEXT_PUBLIC_POSTHOG_KEY: process.env.NEXT_PUBLIC_POSTHOG_KEY,
33 | NEXT_PUBLIC_POSTHOG_HOST: process.env.NEXT_PUBLIC_POSTHOG_HOST,
34 | },
35 | });
36 |
--------------------------------------------------------------------------------
/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 |
5 | import * as Sentry from '@sentry/nextjs';
6 | import * as Spotlight from '@spotlightjs/spotlight';
7 |
8 | Sentry.init({
9 | // Sentry DSN
10 | dsn: process.env.NEXT_PUBLIC_SENTRY_DSN,
11 |
12 | // Add optional integrations for additional features
13 | integrations: [
14 | // You can remove this option if you're not planning to use the Sentry Session Replay feature:
15 | Sentry.replayIntegration({
16 | // Additional Replay configuration goes in here, for example:
17 | maskAllText: true,
18 | blockAllMedia: true,
19 | }),
20 | ],
21 |
22 | // Define how likely traces are sampled. Adjust this value in production, or use tracesSampler for greater control.
23 | tracesSampleRate: 1,
24 |
25 | // Define how likely Replay events are sampled.
26 | // This sets the sample rate to be 10%. You may want this to be 100% while
27 | // in development and sample at a lower rate in production
28 | replaysSessionSampleRate: 0.1,
29 |
30 | // Define how likely Replay events are sampled when an error occurs.
31 | replaysOnErrorSampleRate: 1.0,
32 |
33 | // Setting this option to true will print useful information to the console while you're setting up Sentry.
34 | debug: false,
35 | });
36 |
37 | if (process.env.NODE_ENV === 'development') {
38 | Spotlight.init();
39 | }
40 |
--------------------------------------------------------------------------------
/checkly.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'checkly';
2 | import { EmailAlertChannel, Frequency } from 'checkly/constructs';
3 |
4 | const sendDefaults = {
5 | sendFailure: true,
6 | sendRecovery: true,
7 | sendDegraded: true,
8 | };
9 |
10 | // FIXME: Add your production URL
11 | const productionURL = 'https://demo.nextjs-boilerplate.com';
12 |
13 | const emailChannel = new EmailAlertChannel('email-channel-1', {
14 | // FIXME: add your own email address, Checkly will send you an email notification if a check fails
15 | address: 'contact@creativedesignsguru.com',
16 | ...sendDefaults,
17 | });
18 |
19 | export const config = defineConfig({
20 | // FIXME: Add your own project name, logical ID, and repository URL
21 | projectName: 'Next.js Boilerplate',
22 | logicalId: 'nextjs-boilerplate',
23 | repoUrl: 'https://github.com/ixartz/Next-js-Boilerplate',
24 | checks: {
25 | locations: ['us-east-1', 'eu-west-1'],
26 | tags: ['website'],
27 | runtimeId: '2024.02',
28 | browserChecks: {
29 | frequency: Frequency.EVERY_24H,
30 | testMatch: '**/tests/e2e/**/*.check.e2e.ts',
31 | alertChannels: [emailChannel],
32 | },
33 | playwrightConfig: {
34 | use: {
35 | baseURL: process.env.ENVIRONMENT_URL || productionURL,
36 | extraHTTPHeaders: {
37 | 'x-vercel-protection-bypass': process.env.VERCEL_BYPASS_TOKEN,
38 | },
39 | },
40 | },
41 | },
42 | cli: {
43 | runLocation: 'us-east-1',
44 | reporters: ['list'],
45 | },
46 | });
47 |
48 | export default config;
49 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Visual Splitwise
2 |
3 | A modern web application for visualizing Splitwise group expenses and balances.
4 |
5 | ## Features
6 |
7 | - Interactive bar chart visualization of group member balances
8 | - Real-time data fetching from Splitwise API
9 | - Responsive design with mobile-friendly interface
10 | - Color-coded balance indicators (green for positive, red for negative)
11 | - Group selection dropdown for easy navigation between different groups
12 |
13 | ## Technologies Used
14 |
15 | - Next.js
16 | - React
17 | - Recharts (for data visualization)
18 | - TypeScript
19 | - Tailwind CSS
20 |
21 | ## Getting Started
22 |
23 | 1. Clone the repository
24 | 2. Install dependencies:
25 | ```bash
26 | npm install
27 | ```
28 | 3. Set up your environment variables
29 | 4. Run the development server:
30 | ```bash
31 | npm run dev
32 | ```
33 |
34 | ## Project Structure
35 |
36 | - `/src/app/[locale]/page.tsx` - Main application page with visualization
37 | - `/api/splitwise` - API endpoint for fetching Splitwise data
38 |
39 | ## Data Structure
40 |
41 | The application handles two main types of data:
42 |
43 | ### Group
44 | ```typescript
45 | type Group = {
46 | id: number;
47 | name: string;
48 | members: Member[];
49 | };
50 | ```
51 |
52 | ### Member
53 | ```typescript
54 | type Member = {
55 | id: number;
56 | first_name: string;
57 | last_name: string | null;
58 | balance: Array<{
59 | currency_code: string;
60 | amount: string;
61 | }>;
62 | };
63 | ```
64 |
65 | ## Contributing
66 |
67 | Feel free to submit issues and enhancement requests!
68 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | /* eslint-disable jsonc/sort-keys */
2 | {
3 | "compilerOptions": {
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "module": "esnext",
6 | "moduleResolution": "bundler",
7 | "resolveJsonModule": true,
8 | "removeComments": true,
9 | "preserveConstEnums": true,
10 | "strict": true,
11 | "alwaysStrict": true,
12 | "strictNullChecks": true,
13 | "noUncheckedIndexedAccess": true,
14 |
15 | "noImplicitAny": true,
16 | "noImplicitReturns": true,
17 | "noImplicitThis": true,
18 | "noUnusedLocals": true,
19 | "noUnusedParameters": true,
20 | "allowUnreachableCode": false,
21 | "noFallthroughCasesInSwitch": true,
22 |
23 | "target": "es2017",
24 | "outDir": "out",
25 | "sourceMap": true,
26 |
27 | "esModuleInterop": true,
28 | "allowSyntheticDefaultImports": true,
29 | "allowJs": true,
30 | "checkJs": true,
31 | "skipLibCheck": true,
32 | "forceConsistentCasingInFileNames": true,
33 |
34 | "jsx": "preserve",
35 | "noEmit": true,
36 | "isolatedModules": true,
37 | "incremental": true,
38 |
39 | // Load types
40 | "types": ["vitest/globals"],
41 |
42 | // Path aliases
43 | "baseUrl": ".",
44 | "paths": {
45 | "@/*": ["./src/*"],
46 | "@/public/*": ["./public/*"]
47 | },
48 |
49 | // Editor support
50 | "plugins": [
51 | {
52 | "name": "next"
53 | }
54 | ]
55 | },
56 | "exclude": [
57 | "./out/**/*",
58 | "./node_modules/**/*",
59 | "**/*.spec.ts",
60 | "**/*.e2e.ts"
61 | ],
62 | "include": [
63 | "next-env.d.ts",
64 | "**/*.ts",
65 | "**/*.tsx",
66 | ".storybook/*.ts",
67 | ".next/types/**/*.ts",
68 | "**/*.mts"
69 | ]
70 | }
71 |
--------------------------------------------------------------------------------
/src/app/[locale]/(auth)/dashboard/layout.tsx:
--------------------------------------------------------------------------------
1 | import { LocaleSwitcher } from '@/components/LocaleSwitcher';
2 | import { BaseTemplate } from '@/templates/BaseTemplate';
3 | import { SignOutButton } from '@clerk/nextjs';
4 | import { getTranslations, setRequestLocale } from 'next-intl/server';
5 | import Link from 'next/link';
6 |
7 | export default async function DashboardLayout(props: {
8 | children: React.ReactNode;
9 | params: Promise<{ locale: string }>;
10 | }) {
11 | const { locale } = await props.params;
12 | setRequestLocale(locale);
13 | const t = await getTranslations({
14 | locale,
15 | namespace: 'DashboardLayout',
16 | });
17 |
18 | return (
19 |
22 |
23 |
27 | {t('dashboard_link')}
28 |
29 |
30 |
31 |
35 | {t('user_profile_link')}
36 |
37 |
38 | >
39 | )}
40 | rightNav={(
41 | <>
42 |
43 |
44 |
47 |
48 |
49 |
50 |
51 |
52 |
53 | >
54 | )}
55 | >
56 | {props.children}
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/templates/BaseTemplate.stories.tsx:
--------------------------------------------------------------------------------
1 | import type { Meta, StoryObj } from '@storybook/react';
2 | import messages from '@/locales/en.json';
3 | import { userEvent, within } from '@storybook/test';
4 | import { NextIntlClientProvider } from 'next-intl';
5 | import { BaseTemplate } from './BaseTemplate';
6 |
7 | const meta = {
8 | title: 'Example/BaseTemplate',
9 | component: BaseTemplate,
10 | parameters: {
11 | layout: 'fullscreen',
12 | },
13 | tags: ['autodocs'],
14 | decorators: [
15 | Story => (
16 |
17 |
18 |
19 | ),
20 | ],
21 | } satisfies Meta;
22 |
23 | export default meta;
24 | type Story = StoryObj;
25 |
26 | export const BaseWithReactComponent = {
27 | args: {
28 | children: Children node
,
29 | leftNav: (
30 | <>
31 | Link 1
32 | Link 2
33 | >
34 | ),
35 | },
36 | } satisfies Story;
37 |
38 | export const BaseWithString = {
39 | args: {
40 | children: 'String',
41 | leftNav: (
42 | <>
43 | Link 1
44 | Link 2
45 | >
46 | ),
47 | },
48 | } satisfies Story;
49 |
50 | // More on interaction testing: https://storybook.js.org/docs/7.0/react/writing-tests/interaction-testing
51 | export const BaseWithHomeLink: Story = {
52 | args: {
53 | children: Children node
,
54 | leftNav: (
55 | <>
56 | Link 1
57 | Link 2
58 | >
59 | ),
60 | },
61 | play: async ({ canvasElement }) => {
62 | const canvas = within(canvasElement);
63 | const link = canvas.getByText('Link 1');
64 |
65 | await userEvent.click(link);
66 | },
67 | } satisfies Story;
68 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import antfu from '@antfu/eslint-config';
2 | import nextPlugin from '@next/eslint-plugin-next';
3 | import jestDom from 'eslint-plugin-jest-dom';
4 | import jsxA11y from 'eslint-plugin-jsx-a11y';
5 | import playwright from 'eslint-plugin-playwright';
6 | import testingLibrary from 'eslint-plugin-testing-library';
7 |
8 | export default antfu({
9 | react: true,
10 | typescript: true,
11 |
12 | lessOpinionated: true,
13 | isInEditor: false,
14 |
15 | stylistic: {
16 | semi: true,
17 | },
18 |
19 | formatters: {
20 | css: true,
21 | },
22 |
23 | ignores: [
24 | 'migrations/**/*',
25 | 'next-env.d.ts',
26 | ],
27 | }, jsxA11y.flatConfigs.recommended, {
28 | plugins: {
29 | '@next/next': nextPlugin,
30 | },
31 | rules: {
32 | ...nextPlugin.configs.recommended.rules,
33 | ...nextPlugin.configs['core-web-vitals'].rules,
34 | },
35 | }, {
36 | files: [
37 | '**/*.test.ts?(x)',
38 | ],
39 | ...testingLibrary.configs['flat/react'],
40 | ...jestDom.configs['flat/recommended'],
41 | }, {
42 | files: [
43 | '**/*.spec.ts',
44 | '**/*.e2e.ts',
45 | ],
46 | ...playwright.configs['flat/recommended'],
47 | }, {
48 | rules: {
49 | 'antfu/no-top-level-await': 'off', // Allow top-level await
50 | 'style/brace-style': ['error', '1tbs'], // Use the default brace style
51 | 'ts/consistent-type-definitions': ['error', 'type'], // Use `type` instead of `interface`
52 | 'react/prefer-destructuring-assignment': 'off', // Vscode doesn't support automatically destructuring, it's a pain to add a new variable
53 | 'node/prefer-global/process': 'off', // Allow using `process.env`
54 | 'test/padding-around-all': 'error', // Add padding in test files
55 | 'test/prefer-lowercase-title': 'off', // Allow using uppercase titles in test titles
56 | },
57 | });
58 |
--------------------------------------------------------------------------------
/src/templates/BaseTemplate.test.tsx:
--------------------------------------------------------------------------------
1 | import messages from '@/locales/en.json';
2 | import { render, screen, within } from '@testing-library/react';
3 | import { NextIntlClientProvider } from 'next-intl';
4 | import { BaseTemplate } from './BaseTemplate';
5 |
6 | describe('Base template', () => {
7 | describe('Render method', () => {
8 | it('should have 3 menu items', () => {
9 | render(
10 |
11 |
14 | link 1
15 | link 2
16 | link 3
17 | >
18 | )}
19 | >
20 | {null}
21 |
22 | ,
23 | );
24 |
25 | const menuItemList = screen.getAllByRole('listitem');
26 |
27 | expect(menuItemList).toHaveLength(3);
28 | });
29 |
30 | it('should have a link to support creativedesignsguru.com', () => {
31 | render(
32 |
33 | 1}>{null}
34 | ,
35 | );
36 |
37 | const copyrightSection = screen.getByText(/© Copyright/);
38 | const copyrightLink = within(copyrightSection).getByRole('link');
39 |
40 | /*
41 | * PLEASE READ THIS SECTION
42 | * We'll really appreciate if you could have a link to our website
43 | * The link doesn't need to appear on every pages, one link on one page is enough.
44 | * Thank you for your support it'll mean a lot for us.
45 | */
46 | expect(copyrightLink).toHaveAttribute(
47 | 'href',
48 | 'https://creativedesignsguru.com',
49 | );
50 | });
51 | });
52 | });
53 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.tabSize": 2,
3 | "editor.detectIndentation": false,
4 | "search.exclude": {
5 | "package-lock.json": true
6 | },
7 |
8 | // TypeScript
9 | "typescript.tsdk": "node_modules/typescript/lib", // Use the workspace version of TypeScript
10 | "typescript.enablePromptUseWorkspaceTsdk": true, // For security reasons it's require that users opt into using the workspace version of typescript
11 | "typescript.preferences.autoImportFileExcludePatterns": [
12 | // useRouter should be imported from `next/navigation` instead of `next/router`
13 | "next/router.d.ts",
14 | "next/dist/client/router.d.ts",
15 | // give priority for Link to next/link instead of lucide-react
16 | "lucide-react"
17 | ],
18 | "typescript.preferences.preferTypeOnlyAutoImports": true, // Prefer type-only imports
19 |
20 | // Vitest
21 | "testing.automaticallyOpenTestResults": "neverOpen", // Don't open the test results automatically
22 |
23 | // I18n
24 | "i18n-ally.localesPaths": ["src/locales"],
25 | "i18n-ally.keystyle": "nested",
26 |
27 | // Disable the default formatter, use ESLint instead
28 | "prettier.enable": false,
29 | "editor.formatOnSave": false,
30 |
31 | // Auto fix with ESLint on save
32 | "editor.codeActionsOnSave": [
33 | "source.addMissingImports",
34 | "source.fixAll.eslint"
35 | ],
36 |
37 | // Enable eslint for all supported languages
38 | "eslint.format.enable": true,
39 | "eslint.validate": [
40 | "javascript",
41 | "javascriptreact",
42 | "typescript",
43 | "typescriptreact",
44 | "vue",
45 | "html",
46 | "markdown",
47 | "json",
48 | "jsonc",
49 | "yaml",
50 | "toml",
51 | "xml",
52 | "gql",
53 | "graphql",
54 | "astro",
55 | "css",
56 | "less",
57 | "scss",
58 | "pcss",
59 | "postcss",
60 | "github-actions-workflow"
61 | ]
62 | }
63 |
--------------------------------------------------------------------------------
/tests/e2e/Visual.e2e.ts:
--------------------------------------------------------------------------------
1 | import percySnapshot from '@percy/playwright';
2 | import { expect, test } from '@playwright/test';
3 |
4 | test.describe('Visual testing', () => {
5 | test.describe('Static pages', () => {
6 | test('should take screenshot of the homepage', async ({ page }) => {
7 | await page.goto('/');
8 |
9 | await expect(
10 | page.getByRole('heading', { name: 'Boilerplate Code for Your Next.js Project with Tailwind CSS' }),
11 | ).toBeVisible();
12 |
13 | await percySnapshot(page, 'Homepage');
14 | });
15 |
16 | test('should take screenshot of the about page', async ({ page }) => {
17 | await page.goto('/about');
18 |
19 | await expect(
20 | page.getByRole('link', { name: 'About' }),
21 | ).toBeVisible();
22 |
23 | await percySnapshot(page, 'About');
24 | });
25 |
26 | test('should take screenshot of the portfolio page', async ({ page }) => {
27 | await page.goto('/portfolio');
28 |
29 | await expect(
30 | page.getByText('Welcome to my portfolio page!'),
31 | ).toBeVisible();
32 |
33 | await percySnapshot(page, 'Portfolio');
34 | });
35 |
36 | test('should take screenshot of the portfolio details page', async ({ page }) => {
37 | await page.goto('/portfolio');
38 |
39 | await page.getByRole('link', { name: 'Portfolio 2' }).click();
40 |
41 | await expect(
42 | page.getByText('Created a set of promotional'),
43 | ).toBeVisible();
44 |
45 | await percySnapshot(page, 'Portfolio details');
46 | });
47 |
48 | test('should take screenshot of the French homepage', async ({ page }) => {
49 | await page.goto('/fr');
50 |
51 | await expect(
52 | page.getByRole('heading', { name: 'Code de démarrage pour Next.js avec Tailwind CSS' }),
53 | ).toBeVisible();
54 |
55 | await percySnapshot(page, 'Homepage - French');
56 | });
57 | });
58 | });
59 |
--------------------------------------------------------------------------------
/src/components/VisualSplitwise.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import GroupChart from './GroupChart';
4 | import GroupExpense from './GroupExpense';
5 |
6 | type Member = {
7 | id: number;
8 | first_name: string;
9 | last_name: string | null;
10 | balance: Array<{
11 | currency_code: string;
12 | amount: string;
13 | }>;
14 | };
15 |
16 | type Group = {
17 | id: number;
18 | name: string;
19 | members: Member[];
20 | };
21 |
22 | type VisualSplitwiseProps = {
23 | groups: Group[];
24 | selectedGroup: Group | null;
25 | onGroupChange: (group: Group) => void;
26 | };
27 |
28 | export default function VisualSplitwise({ groups, selectedGroup, onGroupChange }: VisualSplitwiseProps) {
29 | return (
30 |
31 |
Visual Splitwise
32 |
33 |
34 |
51 |
52 |
53 |
54 |
Expenses
55 |
56 |
57 | );
58 | }
59 |
--------------------------------------------------------------------------------
/src/app/[locale]/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from 'next';
2 | import { PostHogProvider } from '@/components/analytics/PostHogProvider';
3 | import { DemoBadge } from '@/components/DemoBadge';
4 | import { routing } from '@/libs/i18nNavigation';
5 | import { NextIntlClientProvider } from 'next-intl';
6 | import { getMessages, setRequestLocale } from 'next-intl/server';
7 | import { notFound } from 'next/navigation';
8 | import '@/styles/global.css';
9 |
10 | export const metadata: Metadata = {
11 | icons: [
12 | {
13 | rel: 'apple-touch-icon',
14 | url: '/apple-touch-icon.png',
15 | },
16 | {
17 | rel: 'icon',
18 | type: 'image/png',
19 | sizes: '32x32',
20 | url: '/favicon-32x32.png',
21 | },
22 | {
23 | rel: 'icon',
24 | type: 'image/png',
25 | sizes: '16x16',
26 | url: '/favicon-16x16.png',
27 | },
28 | {
29 | rel: 'icon',
30 | url: '/favicon.ico',
31 | },
32 | ],
33 | };
34 |
35 | export function generateStaticParams() {
36 | return routing.locales.map(locale => ({ locale }));
37 | }
38 |
39 | export default async function RootLayout(props: {
40 | children: React.ReactNode;
41 | params: Promise<{ locale: string }>;
42 | }) {
43 | const { locale } = await props.params;
44 |
45 | if (!routing.locales.includes(locale)) {
46 | notFound();
47 | }
48 |
49 | setRequestLocale(locale);
50 |
51 | // Using internationalization in Client Components
52 | const messages = await getMessages();
53 |
54 | // The `suppressHydrationWarning` attribute in is used to prevent hydration errors caused by Sentry Overlay,
55 | // which dynamically adds a `style` attribute to the body tag.
56 |
57 | return (
58 |
59 |
60 |
64 |
65 | {props.children}
66 |
67 |
68 |
69 |
70 |
71 | );
72 | }
73 |
--------------------------------------------------------------------------------
/.github/workflows/CI.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches: [main]
6 | pull_request:
7 | branches: [main]
8 |
9 | jobs:
10 | build:
11 | strategy:
12 | matrix:
13 | node-version: [20.x, 22.x]
14 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
15 |
16 | name: Build with ${{ matrix.node-version }}
17 | runs-on: ubuntu-latest
18 |
19 | steps:
20 | - uses: actions/checkout@v4
21 | - name: Use Node.js ${{ matrix.node-version }}
22 | uses: actions/setup-node@v4
23 | with:
24 | node-version: ${{ matrix.node-version }}
25 | cache: npm
26 | - run: npm ci
27 | - run: npm run build
28 |
29 | test:
30 | strategy:
31 | matrix:
32 | node-version: [20.x]
33 |
34 | name: Run all tests
35 | runs-on: ubuntu-latest
36 |
37 | steps:
38 | - uses: actions/checkout@v4
39 | with:
40 | fetch-depth: 0 # Retrieve Git history, needed to verify commits
41 | - name: Use Node.js ${{ matrix.node-version }}
42 | uses: actions/setup-node@v4
43 | with:
44 | node-version: ${{ matrix.node-version }}
45 | cache: npm
46 | - run: npm ci
47 |
48 | - name: Build Next.js for tests
49 | run: npm run build
50 | env:
51 | SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
52 |
53 | - if: github.event_name == 'pull_request'
54 | name: Validate all commits from PR
55 | run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
56 |
57 | - name: Linter
58 | run: npm run lint
59 |
60 | - name: Type checking
61 | run: npm run check-types
62 |
63 | - name: Run unit tests
64 | run: npm run test -- --coverage
65 |
66 | - name: Upload coverage reports to Codecov
67 | uses: codecov/codecov-action@v5
68 | env:
69 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
70 |
71 | - name: Run storybook tests
72 | run: npm run test-storybook:ci
73 |
--------------------------------------------------------------------------------
/tests/e2e/Counter.e2e.ts:
--------------------------------------------------------------------------------
1 | import assert from 'node:assert';
2 | import { faker } from '@faker-js/faker';
3 | import { expect, test } from '@playwright/test';
4 |
5 | test.describe('Counter', () => {
6 | test.describe('Increment operation', () => {
7 | test('should display error message when incrementing with negative number', async ({
8 | page,
9 | }) => {
10 | await page.goto('/counter');
11 |
12 | const count = page.getByText('Count:');
13 | const countText = await count.textContent();
14 |
15 | assert(countText !== null, 'Count should not be null');
16 |
17 | await page.getByLabel('Increment by').fill('-1');
18 | await page.getByRole('button', { name: 'Increment' }).click();
19 |
20 | await expect(page.getByText('Number must be greater than or equal to 1')).toBeVisible();
21 | await expect(page.getByText('Count:')).toHaveText(countText);
22 | });
23 |
24 | test('should increment the counter and validate the count', async ({
25 | page,
26 | }) => {
27 | // `x-e2e-random-id` is used for end-to-end testing to make isolated requests
28 | // The default value is 0 when there is no `x-e2e-random-id` header
29 | const e2eRandomId = faker.number.int({ max: 1000000 });
30 | await page.setExtraHTTPHeaders({
31 | 'x-e2e-random-id': e2eRandomId.toString(),
32 | });
33 | await page.goto('/counter');
34 |
35 | const count = page.getByText('Count:');
36 | const countText = await count.textContent();
37 |
38 | assert(countText !== null, 'Count should not be null');
39 |
40 | const countNumber = Number(countText.split(' ')[1]);
41 |
42 | await page.getByLabel('Increment by').fill('2');
43 | await page.getByRole('button', { name: 'Increment' }).click();
44 |
45 | await expect(page.getByText('Count:')).toHaveText(`Count: ${countNumber + 2}`);
46 |
47 | await page.getByLabel('Increment by').fill('3');
48 | await page.getByRole('button', { name: 'Increment' }).click();
49 |
50 | await expect(page.getByText('Count:')).toHaveText(`Count: ${countNumber + 5}`);
51 | });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/components/CounterForm.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { CounterValidation } from '@/validations/CounterValidation';
4 | import { zodResolver } from '@hookform/resolvers/zod';
5 | import { useTranslations } from 'next-intl';
6 | import { useRouter } from 'next/navigation';
7 | import { useForm } from 'react-hook-form';
8 |
9 | export const CounterForm = () => {
10 | const t = useTranslations('CounterForm');
11 | const form = useForm({
12 | resolver: zodResolver(CounterValidation),
13 | defaultValues: {
14 | increment: 0,
15 | },
16 | });
17 | const router = useRouter();
18 |
19 | const handleIncrement = form.handleSubmit(async (data) => {
20 | await fetch(`/api/counter`, {
21 | method: 'PUT',
22 | headers: {
23 | 'Content-Type': 'application/json',
24 | },
25 | body: JSON.stringify(data),
26 | });
27 |
28 | form.reset();
29 | router.refresh();
30 | });
31 |
32 | return (
33 |
61 | );
62 | };
63 |
--------------------------------------------------------------------------------
/src/templates/BaseTemplate.tsx:
--------------------------------------------------------------------------------
1 | import { AppConfig } from '@/utils/AppConfig';
2 | import { useTranslations } from 'next-intl';
3 |
4 | export const BaseTemplate = (props: {
5 | leftNav: React.ReactNode;
6 | rightNav?: React.ReactNode;
7 | children: React.ReactNode;
8 | }) => {
9 | const t = useTranslations('BaseTemplate');
10 |
11 | return (
12 |
13 |
14 |
36 |
37 |
{props.children}
38 |
39 |
58 |
59 |
60 | );
61 | };
62 |
--------------------------------------------------------------------------------
/playwright.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig, devices } from '@playwright/test';
2 |
3 | // Use process.env.PORT by default and fallback to port 3000
4 | const PORT = process.env.PORT || 3000;
5 |
6 | // Set webServer.url and use.baseURL with the location of the WebServer respecting the correct set port
7 | const baseURL = `http://localhost:${PORT}`;
8 |
9 | /**
10 | * See https://playwright.dev/docs/test-configuration.
11 | */
12 | export default defineConfig({
13 | testDir: './tests',
14 | // Look for files with the .spec.js or .e2e.js extension
15 | testMatch: '*.@(spec|e2e).?(c|m)[jt]s?(x)',
16 | // Timeout per test
17 | timeout: 30 * 1000,
18 | // Fail the build on CI if you accidentally left test.only in the source code.
19 | forbidOnly: !!process.env.CI,
20 | // Reporter to use. See https://playwright.dev/docs/test-reporters
21 | reporter: process.env.CI ? 'github' : 'list',
22 |
23 | expect: {
24 | // Set timeout for async expect matchers
25 | timeout: 10 * 1000,
26 | },
27 |
28 | // Run your local dev server before starting the tests:
29 | // https://playwright.dev/docs/test-advanced#launching-a-development-web-server-during-the-tests
30 | webServer: {
31 | command: process.env.CI ? 'npm run start' : 'npm run dev:next',
32 | url: baseURL,
33 | timeout: 2 * 60 * 1000,
34 | reuseExistingServer: !process.env.CI,
35 | },
36 |
37 | // Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions.
38 | use: {
39 | // Use baseURL so to make navigations relative.
40 | // More information: https://playwright.dev/docs/api/class-testoptions#test-options-base-url
41 | baseURL,
42 |
43 | // Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer
44 | trace: 'retain-on-failure',
45 |
46 | // Record videos when retrying the failed test.
47 | video: process.env.CI ? 'retain-on-failure' : undefined,
48 | },
49 |
50 | projects: [
51 | {
52 | name: 'chromium',
53 | use: { ...devices['Desktop Chrome'] },
54 | },
55 | ...(process.env.CI
56 | ? [
57 | {
58 | name: 'firefox',
59 | use: { ...devices['Desktop Firefox'] },
60 | },
61 | ]
62 | : []),
63 | ],
64 | });
65 |
--------------------------------------------------------------------------------
/tests/e2e/Sanity.check.e2e.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from '@playwright/test';
2 |
3 | // Checkly is a tool used to monitor deployed environments, such as production or preview environments.
4 | // It runs end-to-end tests with the `.check.e2e.ts` extension after each deployment to ensure that the environment is up and running.
5 | // With Checkly, you can monitor your production environment and run `*.check.e2e.ts` tests regularly at a frequency of your choice.
6 | // If the tests fail, Checkly will notify you via email, Slack, or other channels of your choice.
7 | // On the other hand, E2E tests ending with `*.e2e.ts` are only run before deployment.
8 | // You can run them locally or on CI to ensure that the application is ready for deployment.
9 |
10 | // BaseURL needs to be explicitly defined in the test file.
11 | // Otherwise, Checkly runtime will throw an exception: `CHECKLY_INVALID_URL: Only URL's that start with http(s)`
12 | // You can't use `goto` function directly with a relative path like with other *.e2e.ts tests.
13 | // Check the example at https://feedback.checklyhq.com/changelog/new-changelog-436
14 |
15 | test.describe('Sanity', () => {
16 | test.describe('Static pages', () => {
17 | test('should display the homepage', async ({ page, baseURL }) => {
18 | await page.goto(`${baseURL}/`);
19 |
20 | await expect(
21 | page.getByRole('heading', { name: 'Boilerplate Code for Your Next.js Project with Tailwind CSS' }),
22 | ).toBeVisible();
23 | });
24 |
25 | test('should navigate to the about page', async ({ page, baseURL }) => {
26 | await page.goto(`${baseURL}/`);
27 |
28 | await page.getByRole('link', { name: 'About' }).click();
29 |
30 | await expect(page).toHaveURL(/about$/);
31 |
32 | await expect(
33 | page.getByText('Welcome to our About page', { exact: false }),
34 | ).toBeVisible();
35 | });
36 |
37 | test('should navigate to the portfolio page', async ({ page, baseURL }) => {
38 | await page.goto(`${baseURL}/`);
39 |
40 | await page.getByRole('link', { name: 'Portfolio' }).click();
41 |
42 | await expect(page).toHaveURL(/portfolio$/);
43 |
44 | await expect(
45 | page.locator('main').getByRole('link', { name: /^Portfolio/ }),
46 | ).toHaveCount(6);
47 | });
48 | });
49 | });
50 |
--------------------------------------------------------------------------------
/src/middleware.ts:
--------------------------------------------------------------------------------
1 | import type { NextFetchEvent, NextRequest } from 'next/server';
2 | import arcjet from '@/libs/Arcjet';
3 | import { detectBot } from '@arcjet/next';
4 | // import { clerkMiddleware, createRouteMatcher } from '@clerk/nextjs/server';
5 | import createMiddleware from 'next-intl/middleware';
6 | import { NextResponse } from 'next/server';
7 | import { routing } from './libs/i18nNavigation';
8 |
9 | const intlMiddleware = createMiddleware(routing);
10 |
11 | // const isProtectedRoute = createRouteMatcher([
12 | // '/dashboard(.*)',
13 | // '/:locale/dashboard(.*)',
14 | // ]);
15 |
16 | // const isAuthPage = createRouteMatcher([
17 | // '/sign-in(.*)',
18 | // '/:locale/sign-in(.*)',
19 | // '/sign-up(.*)',
20 | // '/:locale/sign-up(.*)',
21 | // ]);
22 |
23 | // Improve security with Arcjet
24 | const aj = arcjet.withRule(
25 | detectBot({
26 | mode: 'LIVE',
27 | // Block all bots except the following
28 | allow: [
29 | // See https://docs.arcjet.com/bot-protection/identifying-bots
30 | 'CATEGORY:SEARCH_ENGINE', // Allow search engines
31 | 'CATEGORY:PREVIEW', // Allow preview links to show OG images
32 | 'CATEGORY:MONITOR', // Allow uptime monitoring services
33 | ],
34 | }),
35 | );
36 |
37 | export default async function middleware(
38 | request: NextRequest,
39 | _event: NextFetchEvent,
40 | ) {
41 | // Verify the request with Arcjet
42 | // Use `process.env` instead of Env to reduce bundle size in middleware
43 | if (process.env.ARCJET_KEY) {
44 | const decision = await aj.protect(request);
45 |
46 | // These errors are handled by the global error boundary, but you could also
47 | // redirect or show a custom error page
48 | if (decision.isDenied()) {
49 | if (decision.reason.isBot()) {
50 | return NextResponse.json(
51 | { error: 'Bot detected' },
52 | { status: 403 },
53 | );
54 | }
55 | }
56 | }
57 |
58 | // Handle internationalization
59 | const response = await intlMiddleware(request);
60 | return response;
61 | }
62 |
63 | export const config = {
64 | matcher: [
65 | // Skip Next.js internals and all static files, unless found in search params
66 | '/((?!_next|monitoring|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)',
67 | // Always run for API routes
68 | '/(api|trpc)(.*)',
69 | ],
70 | };
71 |
--------------------------------------------------------------------------------
/src/app/[locale]/page.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import VisualSplitwise from '@/components/VisualSplitwise';
4 | import { useEffect, useState } from 'react';
5 |
6 | type Member = {
7 | id: number;
8 | first_name: string;
9 | last_name: string | null;
10 | balance: Array<{
11 | currency_code: string;
12 | amount: string;
13 | }>;
14 | };
15 |
16 | type Group = {
17 | id: number;
18 | name: string;
19 | members: Member[];
20 | };
21 |
22 | export default function Home() {
23 | const [groups, setGroups] = useState([]);
24 | const [selectedGroup, setSelectedGroup] = useState(null);
25 | const [isLoading, setIsLoading] = useState(true);
26 | const [error, setError] = useState(null);
27 |
28 | useEffect(() => {
29 | const fetchData = async () => {
30 | try {
31 | const response = await fetch('/api/splitwise/groups/info');
32 | if (!response.ok) {
33 | throw new Error(`HTTP error! status: ${response.status}`);
34 | }
35 |
36 | const data = await response.json();
37 | console.warn(data);
38 | if (!data.groups || !Array.isArray(data.groups)) {
39 | throw new Error('Invalid data format received from API');
40 | }
41 |
42 | setGroups(data.groups);
43 | const firstGroup = data.groups.find((group: Group) => group.id !== 0);
44 | if (firstGroup) {
45 | setSelectedGroup(firstGroup);
46 | }
47 | } catch (error) {
48 | console.error('Error fetching data:', error);
49 | setError(error instanceof Error ? error.message : 'Failed to fetch data');
50 | setGroups([]);
51 | setSelectedGroup(null);
52 | } finally {
53 | setIsLoading(false);
54 | }
55 | };
56 |
57 | fetchData();
58 |
59 | const intervalId = setInterval(fetchData, 30 * 60 * 1000);
60 |
61 | return () => clearInterval(intervalId);
62 | }, []);
63 |
64 | if (isLoading) {
65 | return Loading...
;
66 | }
67 |
68 | if (error) {
69 | return {error}
;
70 | }
71 |
72 | return (
73 |
74 | setSelectedGroup(group)}
78 | />
79 |
80 | );
81 | }
82 |
--------------------------------------------------------------------------------
/next.config.ts:
--------------------------------------------------------------------------------
1 | import withBundleAnalyzer from '@next/bundle-analyzer';
2 | import { withSentryConfig } from '@sentry/nextjs';
3 | import createNextIntlPlugin from 'next-intl/plugin';
4 | import './src/libs/Env';
5 |
6 | const withNextIntl = createNextIntlPlugin('./src/libs/i18n.ts');
7 |
8 | const bundleAnalyzer = withBundleAnalyzer({
9 | enabled: process.env.ANALYZE === 'true',
10 | });
11 |
12 | /** @type {import('next').NextConfig} */
13 | export default withSentryConfig(
14 | bundleAnalyzer(
15 | withNextIntl({
16 | eslint: {
17 | dirs: ['.'],
18 | },
19 | poweredByHeader: false,
20 | reactStrictMode: true,
21 | serverExternalPackages: ['@electric-sql/pglite'],
22 | }),
23 | ),
24 | {
25 | // For all available options, see:
26 | // https://github.com/getsentry/sentry-webpack-plugin#options
27 | // FIXME: Add your Sentry organization and project names
28 | org: 'nextjs-boilerplate-org',
29 | project: 'nextjs-boilerplate',
30 |
31 | // Only print logs for uploading source maps in CI
32 | silent: !process.env.CI,
33 |
34 | // For all available options, see:
35 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/manual-setup/
36 |
37 | // Upload a larger set of source maps for prettier stack traces (increases build time)
38 | widenClientFileUpload: true,
39 |
40 | // Automatically annotate React components to show their full name in breadcrumbs and session replay
41 | reactComponentAnnotation: {
42 | enabled: true,
43 | },
44 |
45 | // Route browser requests to Sentry through a Next.js rewrite to circumvent ad-blockers.
46 | // This can increase your server load as well as your hosting bill.
47 | // Note: Check that the configured route will not match with your Next.js middleware, otherwise reporting of client-
48 | // side errors will fail.
49 | tunnelRoute: '/monitoring',
50 |
51 | // Hides source maps from generated client bundles
52 | hideSourceMaps: true,
53 |
54 | // Automatically tree-shake Sentry logger statements to reduce bundle size
55 | disableLogger: true,
56 |
57 | // Enables automatic instrumentation of Vercel Cron Monitors. (Does not yet work with App Router route handlers.)
58 | // See the following for more information:
59 | // https://docs.sentry.io/product/crons/
60 | // https://vercel.com/docs/cron-jobs
61 | automaticVercelMonitors: true,
62 |
63 | // Disable Sentry telemetry
64 | telemetry: false,
65 | },
66 | );
67 |
--------------------------------------------------------------------------------
/public/assets/images/arcjet-dark.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/public/assets/images/arcjet-light.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/checkly.yml:
--------------------------------------------------------------------------------
1 | name: Checkly
2 |
3 | on: [deployment_status]
4 |
5 | env:
6 | CHECKLY_API_KEY: ${{ secrets.CHECKLY_API_KEY }}
7 | CHECKLY_ACCOUNT_ID: ${{ secrets.CHECKLY_ACCOUNT_ID }}
8 | CHECKLY_TEST_ENVIRONMENT: ${{ github.event.deployment_status.environment }}
9 |
10 | jobs:
11 | test-e2e:
12 | strategy:
13 | matrix:
14 | node-version: [20.x]
15 | # See supported Node.js release schedule at https://nodejs.org/en/about/releases/
16 |
17 | # Only run when the deployment was successful
18 | if: github.event.deployment_status.state == 'success'
19 |
20 | name: Test E2E on Checkly
21 | runs-on: ubuntu-latest
22 | timeout-minutes: 10
23 |
24 | steps:
25 | - uses: actions/checkout@v4
26 | with:
27 | ref: '${{ github.event.deployment_status.deployment.ref }}'
28 | fetch-depth: 0
29 |
30 | - name: Set branch name # workaround to detect branch name in "deployment_status" actions
31 | run: echo "CHECKLY_TEST_REPO_BRANCH=$(git show -s --pretty=%D HEAD | tr -s ',' '\n' | sed 's/^ //' | grep -e 'origin/' | head -1 | sed 's/\origin\///g')" >> $GITHUB_ENV
32 |
33 | - uses: actions/setup-node@v4
34 | with:
35 | node-version: ${{ matrix.node-version }}
36 | cache: npm
37 |
38 | - name: Restore or cache node_modules
39 | id: cache-node-modules
40 | uses: actions/cache@v4
41 | with:
42 | path: node_modules
43 | key: node-modules-${{ hashFiles('package-lock.json') }}
44 |
45 | - name: Install dependencies
46 | if: steps.cache-node-modules.outputs.cache-hit != 'true'
47 | run: npm ci
48 |
49 | - name: Run checks # run the checks passing in the ENVIRONMENT_URL and recording a test session.
50 | id: run-checks
51 | run: npx checkly test --reporter=github --record
52 | env:
53 | VERCEL_BYPASS_TOKEN: ${{ secrets.VERCEL_BYPASS_TOKEN }}
54 | ENVIRONMENT_URL: ${{ github.event.deployment_status.environment_url }}
55 |
56 | - name: Create summary # export the markdown report to the job summary.
57 | id: create-summary
58 | run: cat checkly-github-report.md > $GITHUB_STEP_SUMMARY
59 |
60 | - name: Deploy checks # if the test run was successful and we are on Production, deploy the checks
61 | id: deploy-checks
62 | if: steps.run-checks.outcome == 'success' && github.event.deployment_status.environment == 'Production'
63 | run: npx checkly deploy --force
64 |
--------------------------------------------------------------------------------
/tests/integration/Counter.spec.ts:
--------------------------------------------------------------------------------
1 | import { faker } from '@faker-js/faker';
2 | import { expect, test } from '@playwright/test';
3 |
4 | test.describe('Counter', () => {
5 | test.describe('Basic database operations', () => {
6 | test('shouldn\'t increment the counter with an invalid input', async ({ page }) => {
7 | const counter = await page.request.put('/api/counter', {
8 | data: {
9 | increment: 'incorrect',
10 | },
11 | });
12 |
13 | expect(counter.status()).toBe(422);
14 | });
15 |
16 | test('shouldn\'t increment the counter with a negative number', async ({ page }) => {
17 | const counter = await page.request.put('/api/counter', {
18 | data: {
19 | increment: -1,
20 | },
21 | });
22 |
23 | expect(counter.status()).toBe(422);
24 | });
25 |
26 | test('shouldn\'t increment the counter with a number greater than 3', async ({ page }) => {
27 | const counter = await page.request.put('/api/counter', {
28 | data: {
29 | increment: 5,
30 | },
31 | });
32 |
33 | expect(counter.status()).toBe(422);
34 | });
35 |
36 | test('should increment the counter and update the counter correctly', async ({ page }) => {
37 | // `x-e2e-random-id` is used for end-to-end testing to make isolated requests
38 | // The default value is 0 when there is no `x-e2e-random-id` header
39 | const e2eRandomId = faker.number.int({ max: 1000000 });
40 |
41 | let counter = await page.request.put('/api/counter', {
42 | data: {
43 | increment: 1,
44 | },
45 | headers: {
46 | 'x-e2e-random-id': e2eRandomId.toString(),
47 | },
48 | });
49 | let counterJson = await counter.json();
50 |
51 | expect(counter.status()).toBe(200);
52 |
53 | // Save the current count
54 | const count = counterJson.count;
55 |
56 | counter = await page.request.put('/api/counter', {
57 | data: {
58 | increment: 2,
59 | },
60 | headers: {
61 | 'x-e2e-random-id': e2eRandomId.toString(),
62 | },
63 | });
64 | counterJson = await counter.json();
65 |
66 | expect(counter.status()).toBe(200);
67 | expect(counterJson.count).toEqual(count + 2);
68 |
69 | counter = await page.request.put('/api/counter', {
70 | data: {
71 | increment: 1,
72 | },
73 | headers: {
74 | 'x-e2e-random-id': e2eRandomId.toString(),
75 | },
76 | });
77 | counterJson = await counter.json();
78 |
79 | expect(counter.status()).toBe(200);
80 | expect(counterJson.count).toEqual(count + 3);
81 | });
82 | });
83 | });
84 |
--------------------------------------------------------------------------------
/src/models/Expense.ts:
--------------------------------------------------------------------------------
1 | import type { Model } from 'mongoose';
2 | import mongoose, { Schema } from 'mongoose';
3 |
4 | const ExpenseSchema = new Schema({
5 | id: { type: Number, required: true, unique: true },
6 | group_id: { type: Number, required: true },
7 | expense_bundle_id: { type: Number },
8 | description: { type: String, required: true },
9 | repeats: { type: Boolean, default: false },
10 | repeat_interval: { type: String },
11 | email_reminder: { type: Boolean, default: false },
12 | email_reminder_in_advance: { type: Number, default: -1 },
13 | next_repeat: { type: Date },
14 | details: { type: String },
15 | comments_count: { type: Number, default: 0 },
16 | payment: { type: Boolean, default: false },
17 | creation_method: { type: String, required: true },
18 | transaction_method: { type: String, required: true },
19 | transaction_confirmed: { type: Boolean, default: false },
20 | transaction_id: { type: String },
21 | transaction_status: { type: String },
22 | cost: { type: String, required: true },
23 | currency_code: { type: String, required: true },
24 | repayments: [{
25 | from: { type: Number, required: true },
26 | to: { type: Number, required: true },
27 | amount: { type: String, required: true },
28 | }],
29 | date: { type: Date, required: true },
30 | created_at: { type: Date, required: true },
31 | created_by: {
32 | id: { type: Number, required: true },
33 | first_name: { type: String, required: true },
34 | last_name: { type: String },
35 | },
36 | updated_at: { type: Date, required: true },
37 | updated_by: {
38 | id: { type: Number },
39 | first_name: { type: String },
40 | last_name: { type: String },
41 | },
42 | deleted_at: { type: Date },
43 | deleted_by: {
44 | id: { type: Number },
45 | first_name: { type: String },
46 | last_name: { type: String },
47 | },
48 | category: {
49 | id: { type: Number, required: true },
50 | name: { type: String, required: true },
51 | },
52 | receipt: {
53 | large: { type: String },
54 | original: { type: String },
55 | },
56 | users: [{
57 | user: {
58 | id: { type: Number, required: true },
59 | first_name: { type: String, required: true },
60 | last_name: { type: String },
61 | picture: {
62 | medium: { type: String },
63 | },
64 | custom_picture: { type: Boolean, default: false },
65 | },
66 | user_id: { type: Number, required: true },
67 | paid_share: { type: String, required: true },
68 | owed_share: { type: String, required: true },
69 | net_balance: { type: String, required: true },
70 | }],
71 | }, {
72 | timestamps: true,
73 | });
74 |
75 | ExpenseSchema.index({ group_id: 1 });
76 | ExpenseSchema.index({ date: 1 });
77 |
78 | const Expense = (mongoose.models.Expense || mongoose.model('Expense', ExpenseSchema)) as Model;
79 |
80 | export default Expense;
81 |
--------------------------------------------------------------------------------
/src/app/[locale]/api/splitwise/groups/[groupId]/expenses/route.ts:
--------------------------------------------------------------------------------
1 | import sendMessage from '@/lib/discord/discord';
2 | import { initializeMongoDB } from '@/lib/mongodb/init';
3 | import { logger } from '@/libs/Logger';
4 | import Expense from '@/models/Expense';
5 | import { NextResponse } from 'next/server';
6 |
7 | export async function GET(
8 | _request: Request,
9 | { params }: { params: Promise<{ locale: string; groupId: string }> },
10 | ) {
11 | try {
12 | await initializeMongoDB();
13 | // Get most recent 10 expenses from DB
14 | const { groupId } = await params;
15 | const groupIdNum = Number.parseInt(groupId);
16 | const expenses = await Expense.find({ group_id: groupIdNum }).sort({ createdAt: -1 }).limit(10);
17 | await updateExpenses(groupIdNum);
18 | return NextResponse.json(expenses, {
19 | headers: {
20 | 'Cache-Control': 'public, max-age=1800, s-maxage=1800',
21 | },
22 | });
23 | } catch (error) {
24 | logger.error('Splitwise API Error:', error);
25 | return NextResponse.json(
26 | { error: 'Get Splitwise Expenses Error' },
27 | { status: 500 },
28 | );
29 | }
30 | }
31 |
32 | async function turnExpenseIntoMessage(expense: any) {
33 | const userDetails = expense.users.map((user: any) => {
34 | return `${user.user.first_name} ${user.user.last_name} (已付: ${user.paid_share} CAD, 应付: ${user.owed_share} CAD)`;
35 | }).join('\n');
36 |
37 | return `新支出: ${expense.description}
38 | 金额: ${expense.cost} ${expense.currency_code}
39 | 详情: ${userDetails}
40 | 查看详情: https://live-split-board.hermanyiqunliang.com/`;
41 | }
42 |
43 | async function updateExpenses(groupId: number) {
44 | const response = await fetch(`https://secure.splitwise.com/api/v3.0/get_expenses?group_id=${groupId}&limit=10`, {
45 | headers: {
46 | 'Authorization': `Bearer ${process.env.SPLITWISE_API_KEY}`,
47 | 'Content-Type': 'application/json',
48 | },
49 | });
50 |
51 | if (!response.ok) {
52 | throw new Error(`Splitwise API Error: ${response.status}`);
53 | }
54 |
55 | const data = await response.json();
56 | const expenses = data.expenses;
57 | const messages = [];
58 | for (const expense of expenses) {
59 | // check if expense already exists
60 | const existingExpense = await Expense.findOne({ id: expense.id });
61 | if (existingExpense) {
62 | continue;
63 | }
64 | // if not exists, add to messages
65 | messages.push(await turnExpenseIntoMessage(expense));
66 | // create new expense
67 | const newExpense = new Expense(expense);
68 | await newExpense.save();
69 | }
70 |
71 | // send message to discord
72 | if (messages.length > 0) {
73 | try {
74 | const messageContent = messages.join('\n');
75 | logger.info('Sending Discord message:', { messagesCount: messages.length });
76 | await sendMessage(messageContent);
77 | } catch (error) {
78 | logger.error('Discord message send error:', error);
79 | }
80 | } else {
81 | logger.info('No new expenses to send to Discord');
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/src/locales/en.json:
--------------------------------------------------------------------------------
1 | {
2 | "RootLayout": {
3 | "home_link": "Home",
4 | "about_link": "About",
5 | "counter_link": "Counter",
6 | "portfolio_link": "Portfolio",
7 | "sign_in_link": "Sign in",
8 | "sign_up_link": "Sign up"
9 | },
10 | "BaseTemplate": {
11 | "description": "Starter code for your Nextjs Boilerplate with Tailwind CSS",
12 | "made_with": "Made with ."
13 | },
14 | "Index": {
15 | "meta_title": "Next.js Boilerplate Presentation",
16 | "meta_description": "Next js Boilerplate is the perfect starter code for your project. Build your React application with the Next.js framework.",
17 | "sponsors_title": "Sponsors"
18 | },
19 | "Counter": {
20 | "meta_title": "Counter",
21 | "meta_description": "An example of DB operation",
22 | "loading_counter": "Loading counter...",
23 | "security_powered_by": "Security, bot detection and rate limiting powered by"
24 | },
25 | "CounterForm": {
26 | "presentation": "The counter is stored in the database and incremented by the value you provide.",
27 | "label_increment": "Increment by",
28 | "button_increment": "Increment"
29 | },
30 | "CurrentCount": {
31 | "count": "Count: {count}"
32 | },
33 | "About": {
34 | "meta_title": "About",
35 | "meta_description": "About page description",
36 | "about_paragraph": "Welcome to our About page! We are a team of passionate individuals dedicated to creating amazing software.",
37 | "translation_powered_by": "Translation powered by"
38 | },
39 | "Portfolio": {
40 | "meta_title": "Portfolio",
41 | "meta_description": "Welcome to my portfolio page!",
42 | "presentation": "Welcome to my portfolio page! Here you will find a carefully curated collection of my work and accomplishments. Through this portfolio, I'm to showcase my expertise, creativity, and the value I can bring to your projects.",
43 | "portfolio_name": "Portfolio {name}",
44 | "error_reporting_powered_by": "Error reporting powered by",
45 | "coverage_powered_by": "Code coverage powered by"
46 | },
47 | "PortfolioSlug": {
48 | "meta_title": "Portfolio {slug}",
49 | "meta_description": "Portfolio {slug} description",
50 | "header": "Portfolio {slug}",
51 | "content": "Created a set of promotional materials and branding elements for a corporate event. Crafted a visually unified theme, encompassing a logo, posters, banners, and digital assets. Integrated the client's brand identity while infusing it with a contemporary and innovative approach. Garnered favorable responses from event attendees, resulting in a successful event with heightened participant engagement and increased brand visibility.",
52 | "code_review_powered_by": "Code review powered by"
53 | },
54 | "SignIn": {
55 | "meta_title": "Sign in",
56 | "meta_description": "Seamlessly sign in to your account with our user-friendly login process."
57 | },
58 | "SignUp": {
59 | "meta_title": "Sign up",
60 | "meta_description": "Effortlessly create an account through our intuitive sign-up process."
61 | },
62 | "Dashboard": {
63 | "meta_title": "Dashboard",
64 | "hello_message": "Hello {email}!",
65 | "alternative_message": "Want to build your SaaS faster using the same stack? Try ."
66 | },
67 | "UserProfile": {
68 | "meta_title": "User Profile"
69 | },
70 | "DashboardLayout": {
71 | "dashboard_link": "Dashboard",
72 | "user_profile_link": "Manage your account",
73 | "sign_out": "Sign out"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/locales/fr.json:
--------------------------------------------------------------------------------
1 | {
2 | "RootLayout": {
3 | "home_link": "Accueil",
4 | "about_link": "A propos",
5 | "counter_link": "Compteur",
6 | "portfolio_link": "Portfolio",
7 | "sign_in_link": "Se connecter",
8 | "sign_up_link": "S'inscrire"
9 | },
10 | "BaseTemplate": {
11 | "description": "Code de démarrage pour Next.js avec Tailwind CSS",
12 | "made_with": "Fait avec ."
13 | },
14 | "Index": {
15 | "meta_title": "Présentation de Next.js Boilerplate",
16 | "meta_description": "Next js Boilerplate est le code de démarrage parfait pour votre projet. Construisez votre application React avec le framework Next.js.",
17 | "sponsors_title": "Partenaires"
18 | },
19 | "Counter": {
20 | "meta_title": "Compteur",
21 | "meta_description": "Un exemple d'opération DB",
22 | "loading_counter": "Chargement du compteur...",
23 | "security_powered_by": "Sécurité, détection de bot et rate limiting propulsés par"
24 | },
25 | "CounterForm": {
26 | "presentation": "Le compteur est stocké dans la base de données et incrémenté par la valeur que vous fournissez.",
27 | "label_increment": "Incrémenter de",
28 | "button_increment": "Incrémenter"
29 | },
30 | "CurrentCount": {
31 | "count": "Nombre : {count}"
32 | },
33 | "About": {
34 | "meta_title": "A propos",
35 | "meta_description": "A propos description",
36 | "about_paragraph": "Bienvenue sur notre page À propos ! Nous sommes une équipe de passionnés et dévoués à la création de logiciels.",
37 | "translation_powered_by": "Traduction propulsée par"
38 | },
39 | "Portfolio": {
40 | "meta_title": "Portfolio",
41 | "meta_description": "Bienvenue sur la page de mon portfolio !",
42 | "presentation": "Bienvenue sur ma page portfolio ! Vous trouverez ici une collection soigneusement organisée de mon travail et de mes réalisations. À travers ce portfolio, je mets en valeur mon expertise, ma créativité et la valeur que je peux apporter à vos projets.",
43 | "portfolio_name": "Portfolio {name}",
44 | "error_reporting_powered_by": "Rapport d'erreur propulsé par",
45 | "coverage_powered_by": "Couverture de code propulsée par"
46 | },
47 | "PortfolioSlug": {
48 | "meta_title": "Portfolio {slug}",
49 | "meta_description": "Description du Portfolio {slug}",
50 | "header": "Portfolio {slug}",
51 | "content": "Créé un ensemble de matériel promotionnel et d'éléments de marquage pour un événement d'entreprise. Conçu un thème visuellement unifié, englobant un logo, des affiches, des bannières et des actifs numériques. Intégrer l'identité de marque du client tout en l'insufflant à une approche contemporaine et innovante. Des réponses favorables de la part des participants ont été obtenues, ce qui a donné lieu à un événement réussi avec un engagement accru des participants et une meilleure visibilité de la marque.",
52 | "code_review_powered_by": "Code review propulsé par"
53 | },
54 | "SignIn": {
55 | "meta_title": "Se connecter",
56 | "meta_description": "Connectez-vous à votre compte avec facilité."
57 | },
58 | "SignUp": {
59 | "meta_title": "S'inscrire",
60 | "meta_description": "Créez un compte facilement grâce à notre processus d'inscription intuitif."
61 | },
62 | "Dashboard": {
63 | "meta_title": "Tableau de bord",
64 | "hello_message": "Bonjour {email}!",
65 | "alternative_message": "Vous voulez créer votre SaaS plus rapidement en utilisant la même stack ? Essayez ."
66 | },
67 | "UserProfile": {
68 | "meta_title": "Profil de l'utilisateur"
69 | },
70 | "DashboardLayout": {
71 | "dashboard_link": "Tableau de bord",
72 | "user_profile_link": "Gérer votre compte",
73 | "sign_out": "Se déconnecter"
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/components/GroupExpense.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useEffect, useState } from 'react';
4 |
5 | type Expense = {
6 | id: number;
7 | description: string;
8 | cost: string;
9 | currency_code: string;
10 | date: string;
11 | created_by: {
12 | id: number;
13 | first_name: string;
14 | last_name: string | null;
15 | };
16 | users: {
17 | user: {
18 | id: number;
19 | first_name: string;
20 | last_name: string | null;
21 | };
22 | paid_share: string;
23 | owed_share: string;
24 | }[];
25 | };
26 |
27 | type GroupExpenseProps = {
28 | groupId: number | null;
29 | };
30 |
31 | export default function GroupExpense({ groupId }: GroupExpenseProps) {
32 | const [expenses, setExpenses] = useState([]);
33 | const [loading, setLoading] = useState(false);
34 | const [error, setError] = useState(null);
35 |
36 | useEffect(() => {
37 | const fetchExpenses = async () => {
38 | if (!groupId) {
39 | return;
40 | }
41 |
42 | setLoading(true);
43 | setError(null);
44 |
45 | try {
46 | const response = await fetch(`/api/splitwise/groups/${groupId}/expenses`);
47 | if (!response.ok) {
48 | throw new Error('Failed to fetch expenses');
49 | }
50 | const data = await response.json();
51 | setExpenses(data);
52 | } catch (err) {
53 | setError(err instanceof Error ? err.message : 'An error occurred');
54 | } finally {
55 | setLoading(false);
56 | }
57 | };
58 |
59 | fetchExpenses();
60 | }, [groupId]);
61 |
62 | if (!groupId) {
63 | return Please select a group
;
64 | }
65 |
66 | if (loading) {
67 | return Loading...
;
68 | }
69 |
70 | if (error) {
71 | return {error}
;
72 | }
73 |
74 | return (
75 |
76 |
77 |
78 |
79 |
80 | | Description |
81 | Amount |
82 | Participants |
83 | Date |
84 |
85 |
86 |
87 | {[...expenses].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).map(expense => (
88 |
89 | | {expense.description} |
90 |
91 | {expense.cost}
92 | {' '}
93 | {expense.currency_code.charAt(0)}
94 | |
95 |
96 |
97 | {expense.users.map(user => (
98 |
99 | {user.user.first_name}
100 |
101 | ))}
102 |
103 | |
104 |
105 | {new Date(expense.date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}
106 | |
107 |
108 | ))}
109 |
110 |
111 |
112 |
113 | );
114 | }
115 |
--------------------------------------------------------------------------------
/src/components/GroupChart.tsx:
--------------------------------------------------------------------------------
1 | 'use client';
2 |
3 | import { useMemo } from 'react';
4 | import {
5 | Bar,
6 | BarChart,
7 | CartesianGrid,
8 | Cell,
9 | ResponsiveContainer,
10 | Tooltip,
11 | XAxis,
12 | YAxis,
13 | } from 'recharts';
14 |
15 | type Member = {
16 | id: number;
17 | first_name: string;
18 | last_name: string | null;
19 | balance: Array<{
20 | currency_code: string;
21 | amount: string;
22 | }>;
23 | };
24 |
25 | type Group = {
26 | id: number;
27 | name: string;
28 | members: Member[];
29 | };
30 |
31 | type GroupChartProps = {
32 | group: Group | null;
33 | };
34 |
35 | export default function GroupChart({ group }: GroupChartProps) {
36 | const processChartData = (group: Group) => {
37 | return group.members.map((member) => {
38 | const balance = member.balance.find(b => b.currency_code === 'CAD');
39 | return {
40 | id: member.id,
41 | name: member.first_name,
42 | amount: balance ? Number.parseFloat(balance.amount) : 0,
43 | };
44 | });
45 | };
46 |
47 | const chartData = useMemo(() => {
48 | if (!group) {
49 | return [];
50 | }
51 | return processChartData(group);
52 | }, [group]);
53 |
54 | const CustomTooltip = ({ active, payload, label }: any) => {
55 | if (active && payload && payload.length) {
56 | return (
57 |
58 |
{label}
59 |
60 | {payload[0].value > 0 ? 'To Receive' : 'To Pay'}
61 | : $
62 | {Math.abs(payload[0].value).toFixed(2)}
63 |
64 |
65 | );
66 | }
67 | return null;
68 | };
69 |
70 | if (!group) {
71 | return No group selected
;
72 | }
73 |
74 | return (
75 |
76 |
77 | {chartData.length > 0
78 | ? (
79 |
80 |
87 |
88 |
89 |
101 | } />
102 |
113 | {chartData.map((entry: any) => (
114 | = 0 ? '#4CAF50' : '#FF5252'} />
115 | ))}
116 | |
117 |
118 |
119 | )
120 | : (
121 |
No data available
122 | )}
123 |
124 |
125 | );
126 | }
127 |
--------------------------------------------------------------------------------
/src/components/Sponsors.tsx:
--------------------------------------------------------------------------------
1 | /* eslint-disable react-dom/no-unsafe-target-blank */
2 | import Image from 'next/image';
3 |
4 | export const Sponsors = () => (
5 |
137 | );
138 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "next-js-boilerplate",
3 | "version": "1.2.0",
4 | "author": "Ixartz (https://github.com/ixartz)",
5 | "engines": {
6 | "node": ">=20"
7 | },
8 | "scripts": {
9 | "dev:spotlight": "spotlight-sidecar",
10 | "dev:next": "next dev",
11 | "dev": "run-p dev:*",
12 | "build": "next build",
13 | "start": "next start",
14 | "build-stats": "cross-env ANALYZE=true npm run build",
15 | "clean": "rimraf .next out coverage",
16 | "lint": "eslint .",
17 | "lint:fix": "eslint . --fix",
18 | "check-types": "tsc --noEmit --pretty",
19 | "test": "vitest run",
20 | "test:e2e": "playwright test",
21 | "commit": "cz",
22 | "db:generate": "drizzle-kit generate",
23 | "db:migrate": "dotenv -c production -- drizzle-kit migrate",
24 | "db:studio": "dotenv -c production -- drizzle-kit studio",
25 | "storybook": "storybook dev -p 6006",
26 | "storybook:build": "storybook build",
27 | "storybook:serve": "http-server storybook-static --port 6006 --silent",
28 | "serve-storybook": "run-s storybook:*",
29 | "test-storybook:ci": "start-server-and-test serve-storybook http://127.0.0.1:6006 test-storybook",
30 | "prepare": "husky"
31 | },
32 | "dependencies": {
33 | "@arcjet/next": "^1.0.0-beta.5",
34 | "@clerk/localizations": "^3.13.9",
35 | "@clerk/nextjs": "^6.14.3",
36 | "@electric-sql/pglite": "^0.2.17",
37 | "@hookform/resolvers": "^5.0.1",
38 | "@logtail/pino": "^0.5.2",
39 | "@sentry/nextjs": "^8.55.0",
40 | "@spotlightjs/spotlight": "^2.12.0",
41 | "@t3-oss/env-nextjs": "^0.12.0",
42 | "chart.js": "^4.4.9",
43 | "daisyui": "^5.0.27",
44 | "drizzle-orm": "^0.41.0",
45 | "mongodb": "^6.15.0",
46 | "mongoose": "^8.13.2",
47 | "next": "^15.3.0",
48 | "next-intl": "^3.26.5",
49 | "pg": "^8.14.1",
50 | "pino": "^9.6.0",
51 | "pino-pretty": "^13.0.0",
52 | "posthog-js": "^1.235.6",
53 | "react": "19.1.0",
54 | "react-chartjs-2": "^5.3.0",
55 | "react-dom": "19.1.0",
56 | "react-hook-form": "^7.55.0",
57 | "recharts": "^2.15.3",
58 | "zod": "^3.24.2"
59 | },
60 | "devDependencies": {
61 | "@antfu/eslint-config": "^4.12.0",
62 | "@commitlint/cli": "^19.8.0",
63 | "@commitlint/config-conventional": "^19.8.0",
64 | "@commitlint/cz-commitlint": "^19.8.0",
65 | "@eslint-react/eslint-plugin": "^1.45.2",
66 | "@faker-js/faker": "^9.6.0",
67 | "@next/bundle-analyzer": "^15.3.0",
68 | "@next/eslint-plugin-next": "^15.3.0",
69 | "@percy/cli": "1.30.9",
70 | "@percy/playwright": "^1.0.7",
71 | "@playwright/test": "^1.51.1",
72 | "@semantic-release/changelog": "^6.0.3",
73 | "@semantic-release/git": "^10.0.1",
74 | "@storybook/addon-essentials": "^8.6.12",
75 | "@storybook/addon-interactions": "^8.6.12",
76 | "@storybook/addon-links": "^8.6.12",
77 | "@storybook/addon-onboarding": "^8.6.12",
78 | "@storybook/blocks": "^8.6.12",
79 | "@storybook/nextjs": "^8.6.12",
80 | "@storybook/react": "^8.6.12",
81 | "@storybook/test": "^8.6.12",
82 | "@storybook/test-runner": "^0.22.0",
83 | "@tailwindcss/postcss": "^4.1.3",
84 | "@testing-library/dom": "^10.4.0",
85 | "@testing-library/jest-dom": "^6.6.3",
86 | "@testing-library/react": "^16.3.0",
87 | "@types/node": "^22.14.1",
88 | "@types/pg": "^8.11.12",
89 | "@types/react": "^19.1.1",
90 | "@vitejs/plugin-react": "^4.3.4",
91 | "@vitest/coverage-v8": "^3.1.1",
92 | "@vitest/expect": "^3.1.1",
93 | "checkly": "^5.2.0",
94 | "commitizen": "^4.3.1",
95 | "cross-env": "^7.0.3",
96 | "dotenv-cli": "^8.0.0",
97 | "drizzle-kit": "^0.30.6",
98 | "eslint": "^9.24.0",
99 | "eslint-plugin-format": "^1.0.1",
100 | "eslint-plugin-jest-dom": "^5.5.0",
101 | "eslint-plugin-jsx-a11y": "^6.10.2",
102 | "eslint-plugin-playwright": "^2.2.0",
103 | "eslint-plugin-react-hooks": "^5.2.0",
104 | "eslint-plugin-react-refresh": "^0.4.19",
105 | "eslint-plugin-testing-library": "^7.1.1",
106 | "http-server": "^14.1.1",
107 | "husky": "^9.1.7",
108 | "jsdom": "^26.0.0",
109 | "lint-staged": "^15.5.1",
110 | "npm-run-all": "^4.1.5",
111 | "postcss": "^8.5.3",
112 | "postcss-load-config": "^6.0.1",
113 | "rimraf": "^6.0.1",
114 | "semantic-release": "^24.2.3",
115 | "start-server-and-test": "^2.0.11",
116 | "storybook": "^8.6.12",
117 | "tailwindcss": "^4.1.3",
118 | "ts-node": "^10.9.2",
119 | "typescript": "^5.8.3",
120 | "vite-tsconfig-paths": "^5.1.4",
121 | "vitest": "^3.1.1",
122 | "vitest-fail-on-console": "^0.7.1"
123 | },
124 | "config": {
125 | "commitizen": {
126 | "path": "@commitlint/cz-commitlint"
127 | }
128 | },
129 | "release": {
130 | "branches": [
131 | "main"
132 | ],
133 | "plugins": [
134 | [
135 | "@semantic-release/commit-analyzer",
136 | {
137 | "preset": "conventionalcommits"
138 | }
139 | ],
140 | "@semantic-release/release-notes-generator",
141 | "@semantic-release/changelog",
142 | [
143 | "@semantic-release/npm",
144 | {
145 | "npmPublish": false
146 | }
147 | ],
148 | "@semantic-release/git",
149 | "@semantic-release/github"
150 | ]
151 | }
152 | }
153 |
--------------------------------------------------------------------------------
/public/assets/images/codecov-dark.svg:
--------------------------------------------------------------------------------
1 |
24 |
--------------------------------------------------------------------------------
/public/assets/images/codecov-white.svg:
--------------------------------------------------------------------------------
1 |
29 |
--------------------------------------------------------------------------------
/public/assets/images/coderabbit-logo-dark.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------
/public/assets/images/coderabbit-logo-light.svg:
--------------------------------------------------------------------------------
1 |
22 |
--------------------------------------------------------------------------------