B2B-88: add starter kit structure and elements
This commit is contained in:
94
lib/create-csp-response.ts
Normal file
94
lib/create-csp-response.ts
Normal 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
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
35
lib/dev-mock-modules.ts
Normal 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
49
lib/fonts.ts
Normal 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
31
lib/i18n/i18n.resolver.ts
Normal 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
98
lib/i18n/i18n.server.ts
Normal 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
62
lib/i18n/i18n.settings.ts
Normal 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
13
lib/i18n/with-i18n.tsx
Normal 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
39
lib/root-metdata.ts
Normal 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
49
lib/root-theme.ts
Normal 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;
|
||||
}
|
||||
25
lib/server/require-user-in-server-component.ts
Normal file
25
lib/server/require-user-in-server-component.ts
Normal 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;
|
||||
});
|
||||
Reference in New Issue
Block a user