├── 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 |
3 | 6 |
7 | Demo of 8 | {` Next.js Boilerplate`} 9 |
10 |
11 |
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 |
    34 |

    {t('presentation')}

    35 |
    36 | 45 | 46 | {form.formState.errors.increment?.message && ( 47 |
    {form.formState.errors.increment?.message}
    48 | )} 49 |
    50 | 51 |
    52 | 59 |
    60 |
    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 |
    15 |
    16 |

    17 | {AppConfig.name} 18 |

    19 |

    {t('description')}

    20 |
    21 | 22 |
    23 | 28 | 29 | 34 |
    35 |
    36 | 37 |
    {props.children}
    38 | 39 |
    40 | {`© Copyright ${new Date().getFullYear()} ${AppConfig.name}. `} 41 | {t.rich('made_with', { 42 | author: () => ( 43 | 47 | CreativeDesignsGuru 48 | 49 | ), 50 | })} 51 | {/* 52 | * PLEASE READ THIS SECTION 53 | * I'm an indie maker with limited resources and funds, I'll really appreciate if you could have a link to my website. 54 | * The link doesn't need to appear on every pages, one link on one page is enough. 55 | * For example, in the `About` page. Thank you for your support, it'll mean a lot to me. 56 | */} 57 |
    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 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | {[...expenses].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()).map(expense => ( 88 | 89 | 90 | 95 | 104 | 107 | 108 | ))} 109 | 110 |
    DescriptionAmountParticipantsDate
    {expense.description} 91 | {expense.cost} 92 | {' '} 93 | {expense.currency_code.charAt(0)} 94 | 96 |
    97 | {expense.users.map(user => ( 98 | 99 | {user.user.first_name} 100 | 101 | ))} 102 |
    103 |
    105 | {new Date(expense.date).toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} 106 |
    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 | 6 | 7 | 8 | 22 | 32 | 46 | 47 | 48 | 58 | 68 | 78 | 79 | 80 | 94 | 108 | 122 | 123 | 124 | 134 | 135 | 136 |
    9 | 14 | Clerk – Authentication & User Management for Next.js 20 | 21 | 23 | 24 | CodeRabbit 30 | 31 | 33 | 38 | Sentry 44 | 45 |
    49 | 50 | Arcjet 56 | 57 | 59 | 60 | Sevalla 66 | 67 | 69 | 70 | Crowdin 76 | 77 |
    81 | 86 | PostHog 92 | 93 | 95 | 100 | Better Stack 106 | 107 | 109 | 114 | Checkly 120 | 121 |
    125 | 126 | Next.js SaaS Boilerplate 132 | 133 |
    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 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/assets/images/codecov-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /public/assets/images/coderabbit-logo-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /public/assets/images/coderabbit-logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | --------------------------------------------------------------------------------