feat: add Url Shortener (#895)

* WIP

* added prisma actions

* remove console.logs

* some more fixes

* tweaks

* addressed all PR review comments

* remove hits from the prisma schema and all its corresponding service logic

* add nanoid

* corrected placeholders

* change database model, bring shortUrl service up to Formbricks code conventions

* update UI and shortUrl endpoint to work with new service

---------

Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
Co-authored-by: Matthias Nannt <mail@matthiasnannt.com>
This commit is contained in:
Subham Ray
2023-10-05 12:52:52 +05:30
committed by GitHub
parent 5f19ffabd1
commit ff39086d21
25 changed files with 377 additions and 39 deletions

View File

@@ -10,6 +10,8 @@
WEBAPP_URL=http://localhost:3000
SHORT_SURVEY_BASE_URL=http://localhost:3000/i
##############
# DATABASE #
##############
@@ -108,4 +110,6 @@ CRON_SECRET=
# Encryption key
# You can use: `openssl rand -base64 16` to generate one
FORMBRICKS_ENCRYPTION_KEY=
*/
SHORT_SURVEY_BASE_URL=
*/

View File

@@ -2,17 +2,33 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { prisma } from "@formbricks/database";
import { SHORT_SURVEY_BASE_URL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { createMembership } from "@formbricks/lib/membership/service";
import { createProduct } from "@formbricks/lib/product/service";
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { createShortUrl } from "@formbricks/lib/shortUrl/service";
import { canUserAccessSurvey } from "@formbricks/lib/survey/auth";
import { deleteSurvey, getSurvey } from "@formbricks/lib/survey/service";
import { AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { createTeam, getTeamByEnvironmentId } from "@formbricks/lib/team/service";
import { AuthenticationError, AuthorizationError, ResourceNotFoundError } from "@formbricks/types/v1/errors";
import { Team } from "@prisma/client";
import { Prisma as prismaClient } from "@prisma/client/";
import { getServerSession } from "next-auth";
export const createShortUrlAction = async (url: string) => {
const session = await getServerSession(authOptions);
if (!session) throw new AuthenticationError("Not authenticated");
const regexPattern = new RegExp("^" + SURVEY_BASE_URL);
const isValidUrl = regexPattern.test(url);
if (!isValidUrl) throw new Error("Only Formbricks survey URLs are allowed");
const shortUrl = await createShortUrl(url);
const fullShortUrl = SHORT_SURVEY_BASE_URL + shortUrl.id;
return fullShortUrl;
};
export async function createTeamAction(teamName: string): Promise<Team> {
const session = await getServerSession(authOptions);
if (!session) throw new AuthorizationError("Not authorized");

View File

@@ -1,7 +1,7 @@
export const revalidate = REVALIDATION_INTERVAL;
import Navigation from "@/app/(app)/environments/[environmentId]/Navigation";
import { IS_FORMBRICKS_CLOUD, REVALIDATION_INTERVAL } from "@formbricks/lib/constants";
import Navigation from "@/app/(app)/environments/[environmentId]/components/Navigation";
import { IS_FORMBRICKS_CLOUD, REVALIDATION_INTERVAL, SURVEY_BASE_URL } from "@formbricks/lib/constants";
import { getEnvironment, getEnvironments } from "@formbricks/lib/environment/service";
import { getProducts } from "@formbricks/lib/product/service";
import { getTeamByEnvironmentId, getTeamsByUserId } from "@formbricks/lib/team/service";
@@ -43,6 +43,7 @@ export default async function EnvironmentsNavbar({ environmentId, session }: Env
environments={environments}
session={session}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
surveyBaseUrl={SURVEY_BASE_URL}
/>
);
}

View File

@@ -17,6 +17,7 @@ import {
DropdownMenuTrigger,
} from "@/components/shared/DropdownMenu";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import UrlShortenerModal from "./UrlShortenerModal";
import { formbricksLogout } from "@/lib/formbricks";
import { capitalizeFirstLetter, truncate } from "@/lib/utils";
import formbricks from "@formbricks/js";
@@ -52,6 +53,7 @@ import {
PlusIcon,
UserCircleIcon,
UsersIcon,
LinkIcon,
} from "@heroicons/react/24/solid";
import clsx from "clsx";
import { MenuIcon } from "lucide-react";
@@ -71,6 +73,7 @@ interface NavigationProps {
products: TProduct[];
environments: TEnvironment[];
isFormbricksCloud: boolean;
surveyBaseUrl: string;
}
export default function Navigation({
@@ -81,6 +84,7 @@ export default function Navigation({
products,
environments,
isFormbricksCloud,
surveyBaseUrl,
}: NavigationProps) {
const router = useRouter();
const pathname = usePathname();
@@ -89,6 +93,7 @@ export default function Navigation({
const [widgetSetupCompleted, setWidgetSetupCompleted] = useState(false);
const [showAddProductModal, setShowAddProductModal] = useState(false);
const [showCreateTeamModal, setShowCreateTeamModal] = useState(false);
const [showLinkShortenerModal, setShowLinkShortenerModal] = useState(false);
const product = products.find((product) => product.id === environment.productId);
const [mobileNavMenuOpen, setMobileNavMenuOpen] = useState(false);
@@ -185,6 +190,14 @@ export default function Navigation({
href: `/environments/${environment.id}/settings/setup`,
hidden: widgetSetupCompleted,
},
{
icon: LinkIcon,
label: "Link Shortener",
href: pathname,
onClick: () => {
setShowLinkShortenerModal(true);
},
},
{
icon: CodeBracketIcon,
label: "Developer Docs",
@@ -441,7 +454,7 @@ export default function Navigation({
(link) =>
!link.hidden && (
<Link href={link.href} target={link.target} key={link.label}>
<DropdownMenuItem key={link.label}>
<DropdownMenuItem key={link.label} onClick={link?.onClick}>
<div className="flex items-center">
<link.icon className="mr-2 h-4 w-4" />
<span>{link.label}</span>
@@ -489,6 +502,11 @@ export default function Navigation({
environmentId={environment.id}
/>
<CreateTeamModal open={showCreateTeamModal} setOpen={(val) => setShowCreateTeamModal(val)} />
<UrlShortenerModal
open={showLinkShortenerModal}
setOpen={(val) => setShowLinkShortenerModal(val)}
surveyBaseUrl={surveyBaseUrl}
/>
</nav>
)}
</>

View File

@@ -0,0 +1,152 @@
import Modal from "@/components/shared/Modal";
import { Button, Input, Label } from "@formbricks/ui";
import { LinkIcon } from "@heroicons/react/24/outline";
import clsx from "clsx";
import { useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { createShortUrlAction } from "../actions";
type UrlShortenerModalProps = {
open: boolean;
setOpen: (v: boolean) => void;
surveyBaseUrl: string;
};
type UrlShortenerFormDataProps = {
url: string;
};
type UrlValidationState = "default" | "valid" | "invalid";
export default function UrlShortenerModal({ open, setOpen, surveyBaseUrl }: UrlShortenerModalProps) {
const [urlValidationState, setUrlValidationState] = useState<UrlValidationState>("default");
const [shortUrl, setShortUrl] = useState("");
const {
register,
handleSubmit,
watch,
formState: { isSubmitting },
} = useForm<UrlShortenerFormDataProps>({
mode: "onSubmit",
defaultValues: {
url: "",
},
});
const handleUrlValidation = () => {
const value = watch("url").trim();
if (!value) {
setUrlValidationState("default");
return;
}
const regexPattern = new RegExp("^" + surveyBaseUrl);
const isValid = regexPattern.test(value);
if (!isValid) {
setUrlValidationState("invalid");
toast.error("Only formbricks survey links allowed.");
} else {
setUrlValidationState("valid");
}
};
const shortenUrl = async (data: UrlShortenerFormDataProps) => {
if (urlValidationState !== "valid") return;
const shortUrl = await createShortUrlAction(data.url.trim());
setShortUrl(shortUrl);
};
const resetForm = () => {
setUrlValidationState("default");
setShortUrl("");
};
const copyShortUrlToClipboard = () => {
navigator.clipboard.writeText(shortUrl);
toast.success("URL copied to clipboard!");
};
return (
<Modal
open={open}
setOpen={(v) => {
setOpen(v);
resetForm();
}}
noPadding
closeOnOutsideClick={false}>
<div className="flex h-full flex-col rounded-lg pb-4">
<div className="rounded-t-lg bg-slate-100">
<div className="flex items-center justify-between p-6">
<div className="flex items-center space-x-2">
<div className="mr-1.5 h-10 w-10 text-slate-500">
<LinkIcon />
</div>
<div>
<div className="text-xl font-medium text-slate-700">URL shortener</div>
<div className="text-sm text-slate-500">
Create a short URL to make URL params less obvious.
</div>
</div>
</div>
</div>
</div>
<form onSubmit={handleSubmit(shortenUrl)}>
<div className="grid w-full space-y-2 rounded-lg px-6 py-4">
<Label>Paste URL</Label>
<div className="grid grid-cols-6 gap-3">
<Input
autoFocus
placeholder={`${surveyBaseUrl}...`}
className={clsx(
"col-span-5",
urlValidationState === "valid"
? "border-green-500 bg-green-50"
: urlValidationState === "invalid"
? "border-red-200 bg-red-50"
: urlValidationState === "default"
? "border-slate-200"
: "bg-white"
)}
{...register("url", {
required: true,
})}
onBlur={handleUrlValidation}
/>
<Button
variant="darkCTA"
size="sm"
className="col-span-1 text-center"
type="submit"
loading={isSubmitting}>
Shorten
</Button>
</div>
</div>
</form>
<div className="grid w-full space-y-2 rounded-lg px-6 py-4">
<Label>Short URL</Label>
<div className="grid grid-cols-6 gap-3">
<span
className="col-span-5 cursor-pointer rounded-md border border-slate-300 bg-slate-100 px-3 py-2 text-sm text-slate-700"
onClick={() => {
if (shortUrl) {
copyShortUrlToClipboard();
}
}}>
{shortUrl}
</span>
<Button
variant="secondary"
size="sm"
className="col-span-1 justify-center"
type="button"
onClick={() => copyShortUrlToClipboard()}>
<span>Copy</span>
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -1,10 +1,10 @@
import EnvironmentsNavbar from "@/app/(app)/environments/[environmentId]/EnvironmentsNavbar";
import EnvironmentsNavbar from "@/app/(app)/environments/[environmentId]/components/EnvironmentsNavbar";
import ToasterClient from "@/components/ToasterClient";
import { getServerSession } from "next-auth";
import { redirect } from "next/navigation";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import FormbricksClient from "../../FormbricksClient";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { hasUserEnvironmentAccess } from "@formbricks/lib/environment/auth";
import { IS_FORMBRICKS_CLOUD } from "@formbricks/lib/constants";
import { AuthorizationError } from "@formbricks/types/v1/errors";

View File

@@ -4,7 +4,7 @@ import SummaryHeader from "@/app/(app)/environments/[environmentId]/surveys/[sur
import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/SurveyResultsTabs";
import ResponseTimeline from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/responses/components/ResponseTimeline";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getFilterResponses } from "@/lib/surveys/surveys";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";

View File

@@ -6,7 +6,7 @@ import SurveyResultsTabs from "@/app/(app)/environments/[environmentId]/surveys/
import SummaryList from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryList";
import SummaryMetadata from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryMetadata";
import ContentWrapper from "@/components/shared/ContentWrapper";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { getFilterResponses } from "@/lib/surveys/surveys";
import { TResponse } from "@formbricks/types/v1/responses";
import { TSurveyWithAnalytics } from "@formbricks/types/v1/surveys";

View File

@@ -23,7 +23,10 @@ import { TResponse } from "@formbricks/types/v1/responses";
import { TSurvey } from "@formbricks/types/v1/surveys";
import { createId } from "@paralleldrive/cuid2";
import ResponseFilter from "./ResponseFilter";
import { DateRange, useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import {
DateRange,
useResponseFilter,
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import { TTag } from "@formbricks/types/v1/tags";
enum DateSelected {

View File

@@ -1,6 +1,6 @@
"use client";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import QuestionFilterComboBox from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/QuestionFilterComboBox";
import { TSurveyQuestionType } from "@formbricks/types/v1/surveys";
import { Button, Checkbox, Popover, PopoverContent, PopoverTrigger } from "@formbricks/ui";

View File

@@ -0,0 +1,10 @@
export default function Loading() {
return (
<div className="flex h-full w-full items-center justify-center">
<div className="flex h-1/2 w-1/4 flex-col">
<div className="ph-no-capture h-16 w-1/3 animate-pulse rounded-lg bg-gray-200 font-medium text-slate-900"></div>
<div className="ph-no-capture mt-4 h-full animate-pulse rounded-lg bg-gray-200 text-slate-900"></div>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
import { notFound, redirect } from "next/navigation";
import { getShortUrl } from "@formbricks/lib/shortUrl/service";
import { ZShortUrlId } from "@formbricks/types/v1/shortUrl";
export default async function ShortUrlPage({ params }) {
if (!params.shortUrlId) {
notFound();
}
if (ZShortUrlId.safeParse(params.shortUrlId).success !== true) {
// return not found if unable to parse short url id
notFound();
}
let shortUrl;
try {
shortUrl = await getShortUrl(params.shortUrlId);
} catch (error) {
console.error(error.message);
}
if (shortUrl) {
redirect(shortUrl.url);
}
// return not found if short url not found
notFound();
}

View File

@@ -48,7 +48,8 @@ export const env = createEnv({
INVITE_DISABLED: z.enum(["1", "0"]).optional(),
IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
VERCEL_URL: z.string().optional(),
SURVEY_BASE_URL: z.string().optional(),
SURVEY_BASE_URL: z.string().url().optional(),
SHORT_SURVEY_BASE_URL: z.string().url().optional().or(z.string().length(0)),
GOOGLE_SHEETS_CLIENT_ID: z.string().optional(),
GOOGLE_SHEETS_CLIENT_SECRET: z.string().optional(),
GOOGLE_SHEETS_REDIRECT_URL: z.string().optional(),
@@ -118,6 +119,7 @@ export const env = createEnv({
FORMBRICKS_ENCRYPTION_KEY: process.env.FORMBRICKS_ENCRYPTION_KEY,
VERCEL_URL: process.env.VERCEL_URL,
SURVEY_BASE_URL: process.env.SURVEY_BASE_URL,
SHORT_SURVEY_BASE_URL: process.env.SHORT_SURVEY_BASE_URL,
NEXT_PUBLIC_SENTRY_DSN: process.env.NEXT_PUBLIC_SENTRY_DSN,
},
});

View File

@@ -1,7 +1,7 @@
import {
DateRange,
SelectedFilterValue,
} from "@/app/(app)/environments/[environmentId]/ResponseFilterContext";
} from "@/app/(app)/environments/[environmentId]/components/ResponseFilterContext";
import {
OptionsType,
QuestionOptions,

View File

@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "ShortUrl" (
"id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"url" TEXT NOT NULL,
CONSTRAINT "ShortUrl_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "ShortUrl_url_key" ON "ShortUrl"("url");

View File

@@ -513,3 +513,10 @@ model User {
/// [UserNotificationSettings]
notificationSettings Json @default("{}")
}
model ShortUrl {
id String @id // generate nanoId in service
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
url String @unique
}

View File

@@ -13,6 +13,10 @@ export const WEBAPP_URL =
export const SURVEY_BASE_URL = env.SURVEY_BASE_URL ? env.SURVEY_BASE_URL + "/" : `${WEBAPP_URL}/s/`;
export const SHORT_SURVEY_BASE_URL = env.SHORT_SURVEY_BASE_URL
? env.SHORT_SURVEY_BASE_URL + "/"
: `${WEBAPP_URL}/i/`;
// Other
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET || "";
export const FORMBRICKS_ENCRYPTION_KEY = env.FORMBRICKS_ENCRYPTION_KEY || undefined;

View File

@@ -19,6 +19,7 @@
"next-auth": "^4.22.3",
"jsonwebtoken": "^9.0.2",
"markdown-it": "^13.0.2",
"nanoid": "^5.0.1",
"nodemailer": "^6.9.5",
"posthog-node": "^3.1.2",
"server-only": "^0.0.1",

View File

@@ -0,0 +1,72 @@
import { prisma } from "@formbricks/database";
import { DatabaseError } from "@formbricks/types/v1/errors";
import { TShortUrl, ZShortUrlId } from "@formbricks/types/v1/shortUrl";
import { Prisma } from "@prisma/client";
import { customAlphabet } from "nanoid";
import { validateInputs } from "../utils/validate";
import z from "zod";
// Create the short url and return it
export const createShortUrl = async (url: string): Promise<TShortUrl> => {
validateInputs([url, z.string().url()]);
try {
// Check if an entry with the provided fullUrl already exists.
const existingShortUrl = await getShortUrlByUrl(url);
if (existingShortUrl) {
return existingShortUrl;
}
// If an entry with the provided fullUrl does not exist, create a new one.
const id = customAlphabet("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789", 10)();
return await prisma.shortUrl.create({
data: {
id,
url,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
// Get the full url from short url and return it
export const getShortUrl = async (id: string): Promise<TShortUrl | null> => {
validateInputs([id, ZShortUrlId]);
try {
return await prisma.shortUrl.findUnique({
where: {
id,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const getShortUrlByUrl = async (url: string): Promise<TShortUrl | null> => {
validateInputs([url, z.string().url()]);
try {
return await prisma.shortUrl.findUnique({
where: {
url,
},
});
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -0,0 +1,14 @@
import z from "zod";
export const ZShortUrlId = z.string().length(10);
export type TShortUrlId = z.infer<typeof ZShortUrlId>;
export const ZShortUrl = z.object({
id: ZShortUrlId,
createdAt: z.date(),
updatedAt: z.date(),
url: z.string().url(),
});
export type TShortUrl = z.infer<typeof ZShortUrl>;

42
pnpm-lock.yaml generated
View File

@@ -1,4 +1,4 @@
lockfileVersion: '6.1'
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
@@ -447,7 +447,7 @@ importers:
version: 9.0.0(eslint@8.50.0)
eslint-config-turbo:
specifier: latest
version: 1.10.3(eslint@8.50.0)
version: 1.8.8(eslint@8.50.0)
eslint-plugin-react:
specifier: 7.33.2
version: 7.33.2(eslint@8.50.0)
@@ -553,6 +553,9 @@ importers:
markdown-it:
specifier: ^13.0.2
version: 13.0.2
nanoid:
specifier: ^5.0.1
version: 5.0.1
next-auth:
specifier: ^4.22.3
version: 4.23.1(next@13.5.3)(nodemailer@6.9.5)(react-dom@18.2.0)(react@18.2.0)
@@ -11509,13 +11512,13 @@ packages:
resolution: {integrity: sha512-NB/L/1Y30qyJcG5xZxCJKW/+bqyj+llbcCwo9DEz8bESIP0SLTOQ8T1DWCCFc+wJ61AMEstj4511PSScqMMfCw==}
dev: true
/eslint-config-turbo@1.10.3(eslint@8.50.0):
resolution: {integrity: sha512-ggzPfTJfMsMS383oZ4zfTP1zQvyMyiigOQJRUnLt1nqII6SKkTzdKZdwmXRDHU24KFwUfEFtT6c8vnm2VhL0uQ==}
/eslint-config-turbo@1.8.8(eslint@8.50.0):
resolution: {integrity: sha512-+yT22sHOT5iC1sbBXfLIdXfbZuiv9bAyOXsxTxFCWelTeFFnANqmuKB3x274CFvf7WRuZ/vYP/VMjzU9xnFnxA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
eslint: 8.50.0
eslint-plugin-turbo: 1.10.3(eslint@8.50.0)
eslint-plugin-turbo: 1.8.8(eslint@8.50.0)
dev: true
/eslint-import-resolver-node@0.3.6:
@@ -11729,8 +11732,8 @@ packages:
semver: 6.3.1
string.prototype.matchall: 4.0.8
/eslint-plugin-turbo@1.10.3(eslint@8.50.0):
resolution: {integrity: sha512-g3Mnnk7el1FqxHfqbE/MayLvCsYjA/vKmAnUj66kV4AlM7p/EZqdt42NMcMSKtDVEm0w+utQkkzWG2Xsa0Pd/g==}
/eslint-plugin-turbo@1.8.8(eslint@8.50.0):
resolution: {integrity: sha512-zqyTIvveOY4YU5jviDWw9GXHd4RiKmfEgwsjBrV/a965w0PpDwJgEUoSMB/C/dU310Sv9mF3DSdEjxjJLaw6rA==}
peerDependencies:
eslint: '>6.6.0'
dependencies:
@@ -17188,6 +17191,12 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
/nanoid@5.0.1:
resolution: {integrity: sha512-vWeVtV5Cw68aML/QaZvqN/3QQXc6fBfIieAlu05m7FZW2Dgb+3f0xc0TTxuJW+7u30t7iSDTV/j3kVI0oJqIfQ==}
engines: {node: ^18 || >=20}
hasBin: true
dev: false
/nanomatch@1.2.13:
resolution: {integrity: sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==}
engines: {node: '>=0.10.0'}
@@ -18697,23 +18706,6 @@ packages:
postcss: 8.4.27
yaml: 2.3.1
/postcss-load-config@4.0.1(postcss@8.4.31):
resolution: {integrity: sha512-vEJIc8RdiBRu3oRAI0ymerOn+7rPuMvRXslTvZUKZonDHFIczxztIyJ1urxM1x9JXEikvpWWTUUqal5j/8QgvA==}
engines: {node: '>= 14'}
peerDependencies:
postcss: '>=8.0.9'
ts-node: '>=9.0.0'
peerDependenciesMeta:
postcss:
optional: true
ts-node:
optional: true
dependencies:
lilconfig: 2.1.0
postcss: 8.4.31
yaml: 2.3.1
dev: true
/postcss-loader@4.3.0(postcss@8.4.27)(webpack@4.46.0):
resolution: {integrity: sha512-M/dSoIiNDOo8Rk0mUqoj4kpGq91gcxCfb9PoyZVdZ76/AuhxylHDYZblNE8o+EQ9AMSASeMFEKxZf5aU6wlx1Q==}
engines: {node: '>= 10.13.0'}
@@ -23158,7 +23150,7 @@ packages:
execa: 5.1.1
globby: 11.1.0
joycon: 3.1.1
postcss-load-config: 4.0.1(postcss@8.4.31)
postcss-load-config: 4.0.1(postcss@8.4.27)
resolve-from: 5.0.0
rollup: 3.5.1
source-map: 0.8.0-beta.0

View File

@@ -74,6 +74,7 @@
"IMPRINT_URL",
"NEXT_PUBLIC_SENTRY_DSN",
"SURVEY_BASE_URL",
"SHORT_SURVEY_BASE_URL",
"NODE_ENV",
"NEXT_PUBLIC_POSTHOG_API_HOST",
"NEXT_PUBLIC_POSTHOG_API_KEY",