B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View File

@@ -0,0 +1,94 @@
import type { NoseconeOptions } from '@nosecone/next';
// we need to allow connecting to the Supabase API from the client
const SUPABASE_URL = process.env.NEXT_PUBLIC_SUPABASE_URL as string;
// the URL used for Supabase Realtime
const WEBSOCKET_URL = SUPABASE_URL.replace('https://', 'ws://').replace(
'http://',
'ws://',
);
// disabled to allow loading images from Supabase Storage
const CROSS_ORIGIN_EMBEDDER_POLICY = false;
/**
* @name ALLOWED_ORIGINS
* @description List of allowed origins for the "connectSrc" directive in the Content Security Policy.
*/
const ALLOWED_ORIGINS = [
SUPABASE_URL,
WEBSOCKET_URL,
// add here additional allowed origins
] as never[];
/**
* @name IMG_SRC_ORIGINS
*/
const IMG_SRC_ORIGINS = [SUPABASE_URL] as never[];
/**
* @name UPGRADE_INSECURE_REQUESTS
* @description Upgrade insecure requests to HTTPS when in production
*/
const UPGRADE_INSECURE_REQUESTS = process.env.NODE_ENV === 'production';
/**
* @name createCspResponse
* @description Create a middleware with enhanced headers applied (if applied).
*/
export async function createCspResponse() {
const {
createMiddleware,
withVercelToolbar,
defaults: noseconeConfig,
} = await import('@nosecone/next');
/*
* @name allowedOrigins
* @description List of allowed origins for the "connectSrc" directive in the Content Security Policy.
*/
const config: NoseconeOptions = {
...noseconeConfig,
contentSecurityPolicy: {
directives: {
...noseconeConfig.contentSecurityPolicy.directives,
connectSrc: [
...noseconeConfig.contentSecurityPolicy.directives.connectSrc,
...ALLOWED_ORIGINS,
],
imgSrc: [
...noseconeConfig.contentSecurityPolicy.directives.imgSrc,
...IMG_SRC_ORIGINS,
],
upgradeInsecureRequests: UPGRADE_INSECURE_REQUESTS,
},
},
crossOriginEmbedderPolicy: CROSS_ORIGIN_EMBEDDER_POLICY,
};
const middleware = createMiddleware(
process.env.VERCEL_ENV === 'preview' ? withVercelToolbar(config) : config,
);
// create response
const response = await middleware();
if (response) {
const contentSecurityPolicy = response.headers.get(
'Content-Security-Policy',
);
const matches = contentSecurityPolicy?.match(/nonce-([\w-]+)/) || [];
const nonce = matches[1];
// set x-nonce header if nonce is found
// so we can pass it to client-side scripts
if (nonce) {
response.headers.set('x-nonce', nonce);
}
}
return response;
}

1442
lib/database.types.ts Normal file

File diff suppressed because it is too large Load Diff

35
lib/dev-mock-modules.ts Normal file
View File

@@ -0,0 +1,35 @@
/*
* Mock modules for development.
This file is used to mock the modules that are not needed during development (unless they are used).
It allows the development server to load faster by not loading the modules that are not needed.
*/
const noop = (name: string) => {
return () => {
console.debug(
`The function "${name}" is mocked for development because your environment variables indicate that it is not needed.
If you think this is a mistake, please open a support ticket.`,
);
};
};
// Turnstile
export const Turnstile = undefined;
export const TurnstileProps = {};
// Baselime
export const useBaselimeRum = noop('useBaselimeRum');
export const BaselimeRum = undefined;
// Sentry
export const captureException = noop('Sentry.captureException');
export const captureEvent = noop('Sentry.captureEvent');
export const init = noop('Sentry.init');
export const setUser = noop('Sentry.setUser');
// Stripe
export const loadStripe = noop('Stripe.loadStripe');
// Nodemailer
export const createTransport = noop('Nodemailer.createTransport');

49
lib/fonts.ts Normal file
View File

@@ -0,0 +1,49 @@
import { Inter as SansFont } from 'next/font/google';
import { cn } from '@kit/ui/utils';
/**
* @sans
* @description Define here the sans font.
* By default, it uses the Inter font from Google Fonts.
*/
const sans = SansFont({
subsets: ['latin'],
variable: '--font-sans',
fallback: ['system-ui', 'Helvetica Neue', 'Helvetica', 'Arial'],
preload: true,
weight: ['300', '400', '500', '600', '700'],
});
/**
* @heading
* @description Define here the heading font.
*/
const heading = sans;
// we export these fonts into the root layout
export { sans, heading };
/**
* @name getFontsClassName
* @description Get the class name for the root layout.
* @param theme
*/
export function getFontsClassName(theme?: string) {
const dark = theme === 'dark';
const light = !dark;
const font = [sans.variable, heading.variable].reduce<string[]>(
(acc, curr) => {
if (acc.includes(curr)) return acc;
return [...acc, curr];
},
[],
);
return cn('bg-background min-h-screen antialiased', ...font, {
dark,
light,
});
}

31
lib/i18n/i18n.resolver.ts Normal file
View File

@@ -0,0 +1,31 @@
import { getLogger } from '@kit/shared/logger';
/**
* @name i18nResolver
* @description Resolve the translation file for the given language and namespace in the current application.
* @param language
* @param namespace
*/
export async function i18nResolver(language: string, namespace: string) {
const logger = await getLogger();
try {
const data = await import(
`../../public/locales/${language}/${namespace}.json`
);
return data as Record<string, string>;
} catch (error) {
console.group(
`Error while loading translation file: ${language}/${namespace}`,
);
logger.error(error instanceof Error ? error.message : error);
logger.warn(
`Please create a translation file for this language at "public/locales/${language}/${namespace}.json"`,
);
console.groupEnd();
// return an empty object if the file could not be loaded to avoid loops
return {};
}
}

98
lib/i18n/i18n.server.ts Normal file
View File

@@ -0,0 +1,98 @@
import 'server-only';
import { cache } from 'react';
import { cookies, headers } from 'next/headers';
import { z } from 'zod';
import {
initializeServerI18n,
parseAcceptLanguageHeader,
} from '@kit/i18n/server';
import featuresFlagConfig from '~/config/feature-flags.config';
import {
I18N_COOKIE_NAME,
getI18nSettings,
languages,
} from '~/lib/i18n/i18n.settings';
import { i18nResolver } from './i18n.resolver';
/**
* @name priority
* @description The language priority setting from the feature flag configuration.
*/
const priority = featuresFlagConfig.languagePriority;
/**
* @name createI18nServerInstance
* @description Creates an instance of the i18n server.
* It uses the language from the cookie if it exists, otherwise it uses the language from the accept-language header.
* If neither is available, it will default to the provided environment variable.
*
* Initialize the i18n instance for every RSC server request (eg. each page/layout)
*/
async function createInstance() {
const cookieStore = await cookies();
const langCookieValue = cookieStore.get(I18N_COOKIE_NAME)?.value;
let selectedLanguage: string | undefined = undefined;
// if the cookie is set, use the language from the cookie
if (langCookieValue) {
selectedLanguage = getLanguageOrFallback(langCookieValue);
}
// if not, check if the language priority is set to user and
// use the user's preferred language
if (!selectedLanguage && priority === 'user') {
const userPreferredLanguage = await getPreferredLanguageFromBrowser();
selectedLanguage = getLanguageOrFallback(userPreferredLanguage);
}
const settings = getI18nSettings(selectedLanguage);
return initializeServerI18n(settings, i18nResolver);
}
export const createI18nServerInstance = cache(createInstance);
/**
* @name getPreferredLanguageFromBrowser
* Get the user's preferred language from the accept-language header.
*/
async function getPreferredLanguageFromBrowser() {
const headersStore = await headers();
const acceptLanguage = headersStore.get('accept-language');
// no accept-language header, return
if (!acceptLanguage) {
return;
}
return parseAcceptLanguageHeader(acceptLanguage, languages)[0];
}
/**
* @name getLanguageOrFallback
* Get the language or fallback to the default language.
* @param selectedLanguage
*/
function getLanguageOrFallback(selectedLanguage: string | undefined) {
const language = z
.enum(languages as [string, ...string[]])
.safeParse(selectedLanguage);
if (language.success) {
return language.data;
}
console.warn(
`The language passed is invalid. Defaulted back to "${languages[0]}"`,
);
return languages[0];
}

62
lib/i18n/i18n.settings.ts Normal file
View File

@@ -0,0 +1,62 @@
import { createI18nSettings } from '@kit/i18n';
/**
* The default language of the application.
* This is used as a fallback language when the selected language is not supported.
*
*/
const defaultLanguage = process.env.NEXT_PUBLIC_DEFAULT_LOCALE ?? 'en';
/**
* The list of supported languages.
* By default, only the default language is supported.
* Add more languages here if needed.
*/
export const languages: string[] = [defaultLanguage];
/**
* The name of the cookie that stores the selected language.
*/
export const I18N_COOKIE_NAME = 'lang';
/**
* The default array of Internationalization (i18n) namespaces.
* These namespaces are commonly used in the application for translation purposes.
*
* Add your own namespaces here
**/
export const defaultI18nNamespaces = [
'common',
'auth',
'account',
'teams',
'billing',
'marketing',
];
/**
* Get the i18n settings for the given language and namespaces.
* If the language is not supported, it will fall back to the default language.
* @param language
* @param ns
*/
export function getI18nSettings(
language: string | undefined,
ns: string | string[] = defaultI18nNamespaces,
) {
let lng = language ?? defaultLanguage;
if (!languages.includes(lng)) {
console.warn(
`Language "${lng}" is not supported. Falling back to "${defaultLanguage}"`,
);
lng = defaultLanguage;
}
return createI18nSettings({
language: lng,
namespaces: ns,
languages,
});
}

13
lib/i18n/with-i18n.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { createI18nServerInstance } from './i18n.server';
type LayoutOrPageComponent<Params> = React.ComponentType<Params>;
export function withI18n<Params extends object>(
Component: LayoutOrPageComponent<Params>,
) {
return async function I18nServerComponentWrapper(params: Params) {
await createI18nServerInstance();
return <Component {...params} />;
};
}

39
lib/root-metdata.ts Normal file
View File

@@ -0,0 +1,39 @@
import { Metadata } from 'next';
import { headers } from 'next/headers';
import appConfig from '~/config/app.config';
/**
* @name generateRootMetadata
* @description Generates the root metadata for the application
*/
export const generateRootMetadata = async (): Promise<Metadata> => {
const headersStore = await headers();
const csrfToken = headersStore.get('x-csrf-token') ?? '';
return {
title: appConfig.title,
description: appConfig.description,
metadataBase: new URL(appConfig.url),
applicationName: appConfig.name,
other: {
'csrf-token': csrfToken,
},
openGraph: {
url: appConfig.url,
siteName: appConfig.name,
title: appConfig.title,
description: appConfig.description,
},
twitter: {
card: 'summary_large_image',
title: appConfig.title,
description: appConfig.description,
},
icons: {
icon: '/images/favicon/favicon.ico',
apple: '/images/favicon/apple-touch-icon.png',
},
};
};

49
lib/root-theme.ts Normal file
View File

@@ -0,0 +1,49 @@
import { cookies } from 'next/headers';
import { z } from 'zod';
/**
* @name Theme
* @description The theme mode enum.
*/
const Theme = z.enum(['light', 'dark', 'system'], {
description: 'The theme mode',
});
/**
* @name appDefaultThemeMode
* @description The default theme mode set by the application.
*/
const appDefaultThemeMode = Theme.safeParse(
process.env.NEXT_PUBLIC_DEFAULT_THEME_MODE,
);
/**
* @name fallbackThemeMode
* @description The fallback theme mode if none of the other options are available.
*/
const fallbackThemeMode = `light`;
/**
* @name getRootTheme
* @description Get the root theme from the cookies or default theme.
* @returns The root theme.
*/
export async function getRootTheme() {
const cookiesStore = await cookies();
const themeCookieValue = cookiesStore.get('theme')?.value;
const theme = Theme.safeParse(themeCookieValue);
// pass the theme from the cookie if it exists
if (theme.success) {
return theme.data;
}
// pass the default theme from the environment variable if it exists
if (appDefaultThemeMode.success) {
return appDefaultThemeMode.data;
}
// in all other cases, fallback to the default theme
return fallbackThemeMode;
}

View File

@@ -0,0 +1,25 @@
import 'server-only';
import { cache } from 'react';
import { redirect } from 'next/navigation';
import { requireUser } from '@kit/supabase/require-user';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
/**
* @name requireUserInServerComponent
* @description Require the user to be authenticated in a server component.
* We reuse this function in multiple server components - it is cached so that the data is only fetched once per request.
* Use this instead of `requireUser` in server components, so you don't need to hit the database multiple times in a single request.
*/
export const requireUserInServerComponent = cache(async () => {
const client = getSupabaseServerClient();
const result = await requireUser(client);
if (result.error) {
redirect(result.redirectTo);
}
return result.data;
});