feat: add pretty URL UI components for surveys (#6969)

Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
This commit is contained in:
Johannes
2025-12-23 22:39:46 -08:00
committed by GitHub
parent 939f135bf4
commit 65abd4ee07
39 changed files with 1155 additions and 23 deletions

View File

@@ -144,6 +144,12 @@ export const OrganizationBreadcrumb = ({
href: `/environments/${currentEnvironmentId}/settings/api-keys`,
hidden: !isOwnerOrManager,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${currentEnvironmentId}/settings/domain`,
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),

View File

@@ -47,6 +47,13 @@ export const OrganizationSettingsNavbar = ({
current: pathname?.includes("/api-keys"),
hidden: !isOwner,
},
{
id: "domain",
label: t("common.domain"),
href: `/environments/${environmentId}/settings/domain`,
current: pathname?.includes("/domain"),
hidden: isFormbricksCloud,
},
{
id: "billing",
label: t("common.billing"),

View File

@@ -0,0 +1,102 @@
"use client";
import Link from "next/link";
import { useTranslation } from "react-i18next";
import { TSurveyStatus } from "@formbricks/types/surveys/types";
import { IdBadge } from "@/modules/ui/components/id-badge";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
interface SurveyWithSlug {
id: string;
name: string;
slug: string | null;
status: TSurveyStatus;
environment: {
id: string;
type: "production" | "development";
project: {
id: string;
name: string;
};
};
createdAt: Date;
}
interface PrettyUrlsTableProps {
surveys: SurveyWithSlug[];
}
export const PrettyUrlsTable = ({ surveys }: PrettyUrlsTableProps) => {
const { t } = useTranslation();
const getEnvironmentBadgeColor = (type: string) => {
return type === "production" ? "bg-green-100 text-green-800" : "bg-blue-100 text-blue-800";
};
const tableHeaders = [
{
label: t("environments.settings.domain.survey_name"),
key: "name",
},
{
label: t("environments.settings.domain.project"),
key: "project",
},
{
label: t("environments.settings.domain.pretty_url"),
key: "slug",
},
{
label: t("common.environment"),
key: "environment",
},
];
return (
<div className="overflow-hidden rounded-lg">
<Table>
<TableHeader>
<TableRow className="bg-slate-100">
{tableHeaders.map((header) => (
<TableHead key={header.key} className="font-medium text-slate-500">
{header.label}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody className="[&_tr:last-child]:border-b">
{surveys.length === 0 && (
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="text-center text-slate-500">
{t("environments.settings.domain.no_pretty_urls")}
</TableCell>
</TableRow>
)}
{surveys.map((survey) => (
<TableRow key={survey.id} className="border-slate-200 hover:bg-transparent">
<TableCell className="font-medium">
<Link
href={`/environments/${survey.environment.id}/surveys/${survey.id}/summary`}
className="text-slate-900 hover:text-slate-700 hover:underline">
{survey.name}
</Link>
</TableCell>
<TableCell>{survey.environment.project.name}</TableCell>
<TableCell>
<IdBadge id={survey.slug ?? ""} />
</TableCell>
<TableCell>
<span
className={`rounded px-2 py-1 text-xs font-medium ${getEnvironmentBadgeColor(survey.environment.type)}`}>
{survey.environment.type === "production"
? t("common.production")
: t("common.development")}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
};

View File

@@ -0,0 +1,53 @@
import { notFound } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getTranslate } from "@/lingodotdev/server";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { getSurveysWithSlugsByOrganizationId } from "@/modules/survey/lib/slug";
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
import { SettingsCard } from "../../components/SettingsCard";
import { OrganizationSettingsNavbar } from "../components/OrganizationSettingsNavbar";
import { PrettyUrlsTable } from "./components/pretty-urls-table";
const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
if (!session) {
throw new Error(t("common.session_not_found"));
}
const result = await getSurveysWithSlugsByOrganizationId(organization.id);
if (!result.ok) {
throw new Error(t("common.something_went_wrong"));
}
const surveys = result.data;
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
<OrganizationSettingsNavbar
environmentId={params.environmentId}
isFormbricksCloud={IS_FORMBRICKS_CLOUD}
membershipRole={currentUserMembership?.role}
activeId="domain"
/>
</PageHeader>
<SettingsCard
title={t("environments.settings.domain.title")}
description={t("environments.settings.domain.description")}>
<PrettyUrlsTable surveys={surveys} />
</SettingsCard>
</PageContentWrapper>
);
};
export default Page;

View File

@@ -3,7 +3,7 @@
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
import {
Code2Icon,
LinkIcon,
Link2Icon,
MailIcon,
QrCodeIcon,
Settings,
@@ -22,6 +22,7 @@ import { DynamicPopupTab } from "@/app/(app)/environments/[environmentId]/survey
import { EmailTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/email-tab";
import { LinkSettingsTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/link-settings-tab";
import { PersonalLinksTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/personal-links-tab";
import { PrettyUrlTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/pretty-url-tab";
import { QRCodeTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/qr-code-tab";
import { SocialMediaTab } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/social-media-tab";
import { TabContainer } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/shareEmbedModal/tab-container";
@@ -80,13 +81,13 @@ export const ShareSurveyModal = ({
componentType: React.ComponentType<unknown>;
componentProps: unknown;
disabled?: boolean;
}[] = useMemo(
() => [
}[] = useMemo(() => {
const tabs = [
{
id: ShareViaType.ANON_LINKS,
type: LinkTabsType.SHARE_VIA,
label: t("environments.surveys.share.anonymous_links.nav_title"),
icon: LinkIcon,
icon: Link2Icon,
title: t("environments.surveys.share.anonymous_links.nav_title"),
description: t("environments.surveys.share.anonymous_links.description"),
componentType: AnonymousLinksTab,
@@ -180,22 +181,33 @@ export const ShareSurveyModal = ({
componentType: LinkSettingsTab,
componentProps: { isReadOnly, locale: user.locale, isStorageConfigured },
},
],
[
t,
survey,
publicDomain,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
]
);
{
id: ShareSettingsType.PRETTY_URL,
type: LinkTabsType.SHARE_SETTING,
label: t("environments.surveys.share.pretty_url.title"),
icon: Link2Icon,
title: t("environments.surveys.share.pretty_url.title"),
description: t("environments.surveys.share.pretty_url.description"),
componentType: PrettyUrlTab,
componentProps: { publicDomain, isReadOnly },
},
];
return isFormbricksCloud ? tabs.filter((tab) => tab.id !== ShareSettingsType.PRETTY_URL) : tabs;
}, [
t,
survey,
publicDomain,
user.locale,
surveyUrl,
isReadOnly,
environmentId,
segments,
isContactsEnabled,
isFormbricksCloud,
email,
isStorageConfigured,
]);
const getDefaultActiveId = useCallback(() => {
if (survey.type !== "link") {

View File

@@ -0,0 +1,31 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@/modules/ui/components/input";
interface PrettyUrlInputProps {
value: string;
onChange: (value: string) => void;
publicDomain: string;
disabled?: boolean;
}
export const PrettyUrlInput = ({ value, onChange, publicDomain, disabled = false }: PrettyUrlInputProps) => {
const { t } = useTranslation();
return (
<div className="flex items-center overflow-hidden rounded-md border border-slate-300 bg-white">
<span className="flex-shrink-0 border-r border-slate-300 bg-slate-50 px-3 py-2 text-sm text-slate-600">
{publicDomain}/p/
</span>
<Input
type="text"
value={value}
onChange={(e) => onChange(e.target.value.toLowerCase().replaceAll(/[^a-z0-9-]/g, ""))}
placeholder={t("environments.surveys.share.pretty_url.slug_placeholder")}
disabled={disabled}
className="border-0 bg-white focus-visible:ring-0 focus-visible:ring-offset-0"
/>
</div>
);
};

View File

@@ -0,0 +1,194 @@
"use client";
import { Copy, Trash2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useState } from "react";
import { useForm } from "react-hook-form";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { useSurvey } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/context/survey-context";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { removeSurveySlugAction, updateSurveySlugAction } from "@/modules/survey/slug/actions";
import { Button } from "@/modules/ui/components/button";
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
import {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormProvider,
} from "@/modules/ui/components/form";
import { PrettyUrlInput } from "./pretty-url-input";
interface PrettyUrlTabProps {
publicDomain: string;
isReadOnly?: boolean;
}
interface PrettyUrlFormData {
slug: string;
}
export const PrettyUrlTab = ({ publicDomain, isReadOnly = false }: PrettyUrlTabProps) => {
const { t } = useTranslation();
const router = useRouter();
const { survey } = useSurvey();
const [isEditing, setIsEditing] = useState(!survey.slug);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
// Initialize form with current values - memoize to prevent re-initialization
const initialFormData = useMemo(() => {
return {
slug: survey.slug || "",
};
}, [survey.slug]);
const form = useForm<PrettyUrlFormData>({
defaultValues: initialFormData,
});
const { handleSubmit, reset } = form;
// Sync isEditing state and form with survey.slug changes
useEffect(() => {
setIsEditing(!survey.slug);
reset({ slug: survey.slug || "" });
}, [survey.slug, reset]);
const onSubmit = async (data: PrettyUrlFormData) => {
if (!data.slug.trim()) {
toast.error(t("environments.surveys.share.pretty_url.slug_required"));
return;
}
setIsSubmitting(true);
const result = await updateSurveySlugAction({
surveyId: survey.id,
slug: data.slug,
});
if (result?.data) {
if (result.data.ok) {
toast.success(t("environments.surveys.share.pretty_url.save_success"));
router.refresh();
setIsEditing(false);
} else {
toast.error(result.data.error.message);
}
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage || "Failed to update slug");
}
setIsSubmitting(false);
};
const handleEdit = () => {
setIsEditing(true);
};
const handleCancel = () => {
reset({ slug: survey.slug || "" });
setIsEditing(false);
};
const handleRemove = async () => {
setIsSubmitting(true);
const result = await removeSurveySlugAction({ surveyId: survey.id });
if (result?.data) {
if (result.data.ok) {
setShowRemoveDialog(false);
reset({ slug: "" });
router.refresh();
setIsEditing(true);
toast.success(t("environments.surveys.share.pretty_url.remove_success"));
} else {
toast.error(result.data.error.message);
}
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage || "Failed to remove slug");
}
setIsSubmitting(false);
};
const handleCopyUrl = () => {
if (!survey.slug) return;
const prettyUrl = `${publicDomain}/p/${survey.slug}`;
navigator.clipboard.writeText(prettyUrl);
toast.success(t("common.copied_to_clipboard"));
};
return (
<div className="px-1">
<FormProvider {...form}>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<FormField
control={form.control}
name="slug"
render={({ field }) => (
<FormItem>
<FormLabel>{t("environments.surveys.share.pretty_url.slug_label")}</FormLabel>
<FormControl>
<PrettyUrlInput
value={field.value}
onChange={field.onChange}
publicDomain={publicDomain}
disabled={isReadOnly || !isEditing}
/>
</FormControl>
<FormDescription>{t("environments.surveys.share.pretty_url.slug_help")}</FormDescription>
</FormItem>
)}
/>
<div className="flex gap-2">
{isEditing ? (
<>
<Button type="submit" disabled={isReadOnly || isSubmitting}>
{t("common.save")}
</Button>
{survey.slug && (
<Button type="button" variant="secondary" onClick={handleCancel} disabled={isSubmitting}>
{t("common.cancel")}
</Button>
)}
</>
) : (
<Button type="button" variant="secondary" onClick={handleEdit} disabled={isReadOnly}>
{t("common.edit")}
</Button>
)}
{survey.slug && !isEditing && (
<>
<Button type="button" variant="default" onClick={handleCopyUrl} disabled={isReadOnly}>
<Copy className="mr-2 h-4 w-4" />
{t("common.copy")} URL
</Button>
<Button
type="button"
variant="destructive"
onClick={() => setShowRemoveDialog(true)}
disabled={isReadOnly}>
<Trash2 className="mr-2 h-4 w-4" />
{t("common.remove")}
</Button>
</>
)}
</div>
</form>
</FormProvider>
<DeleteDialog
open={showRemoveDialog}
setOpen={setShowRemoveDialog}
deleteWhat={t("environments.surveys.share.pretty_url.title")}
onDelete={handleRemove}
text={t("environments.surveys.share.pretty_url.remove_description")}></DeleteDialog>
</div>
);
};

View File

@@ -12,6 +12,7 @@ export enum ShareViaType {
export enum ShareSettingsType {
LINK_SETTINGS = "link-settings",
PRETTY_URL = "pretty-url",
}
export enum LinkTabsType {

View File

@@ -4915,5 +4915,6 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
isBackButtonHidden: false,
metadata: {},
questions: [], // Required for build-time type checking (Zod defaults to [] at runtime)
slug: null,
};
};

View File

@@ -0,0 +1,5 @@
import { LinkSurveyNotFound } from "@/modules/survey/link/not-found";
export default function NotFound() {
return <LinkSurveyNotFound />;
}

View File

@@ -0,0 +1,34 @@
import { notFound, redirect } from "next/navigation";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { getSurveyBySlug } from "@/modules/survey/lib/slug";
interface PrettyUrlPageProps {
params: Promise<{ slug: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
}
export default async function PrettyUrlPage(props: PrettyUrlPageProps) {
const { slug } = await props.params;
const searchParams = await props.searchParams;
if (IS_FORMBRICKS_CLOUD) {
return notFound();
}
const result = await getSurveyBySlug(slug);
if (!result.ok || !result.data) {
return notFound();
}
const survey = result.data;
// Preserve query params (suId, lang, etc.)
const queryString = new URLSearchParams(
Object.entries(searchParams).filter(([_, v]) => v !== undefined) as [string, string][]
).toString();
const baseUrl = `/s/${survey.id}`;
const redirectUrl = queryString ? `${baseUrl}?${queryString}` : baseUrl;
redirect(redirectUrl);
}

View File

@@ -160,6 +160,7 @@ checksums:
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
common/description: e17686a22ffad04cc7bb70524ed4478b
common/dev_env: e650911d5e19ba256358e0cda154c005
common/development: 85211dbb918bda7a6e87649dcfc1b17a
common/development_environment_banner: 90aa859c1f43a9c2d9fbc758853d0c20
common/disable: 81b754fd7962e0bd9b6ba87f3972e7fc
common/disallow: 144b91c92ed07f29ff362fa9edc2e4d0
@@ -167,6 +168,7 @@ checksums:
common/dismissed: f0e21b3fe28726c577a7238a63cc29c2
common/docs: 1563fcb5ddb5037b0709ccd3dd384a92
common/documentation: 1563fcb5ddb5037b0709ccd3dd384a92
common/domain: 402d46965eacc3af4c5df92e53e95712
common/download: 56b7d0834952b39ee394b44bd8179178
common/draft: e8a92958ad300aacfe46c2bf6644927e
common/duplicate: 27756566785c2b8463e21582c4bb619b
@@ -176,6 +178,7 @@ checksums:
common/ending_card: 16d30d3a36472159da8c2dbd374dfe22
common/enter_url: 468c2276d0f2cb971ff5a47a20fa4b97
common/enterprise_license: e81bf506f47968870c7bd07245648a0d
common/environment: 0844e8dc1485339c8de066dc0a9bb6a1
common/environment_not_found: 4d7610bdb55a8b5e6131bb5b08ce04c5
common/environment_notice: 0a860e3fa89407726dd8a2083a6b7fd5
common/error: 3c95bcb32c2104b99a46f5b3dd015248
@@ -294,6 +297,7 @@ checksums:
common/preview_survey: 7409e9c118e3e5d5f2a86201c2b354f2
common/privacy: 7459744a63ef8af4e517a09024bd7c08
common/product_manager: dfeadc96e6d3de22a884ee97974b505e
common/production: 226e0ce83b49700bc1b1c08c4c3ed23a
common/profile: d7878693f91303a438852d617f6d35df
common/profile_id: 0ef1286cce9d47b148e9a09deccb6655
common/progress: dd0200d5849ebb7d64c15098ae91d229
@@ -954,6 +958,12 @@ checksums:
environments/settings/billing/upgrade: 63c3b52882e0d779859307d672c178c2
environments/settings/billing/uptime_sla_99: 25ca4060e575e1a7eee47fceb5576d7c
environments/settings/billing/website_surveys: f4d176cc66ffcc2abf44c0d5da1642e3
environments/settings/domain/description: f0b4d8c96da816f793cf1f4fdfaade34
environments/settings/domain/no_pretty_urls: 2a83d8e399d325fcd4e2db14e45b1a86
environments/settings/domain/pretty_url: 10a4b387b6df844245fc842be29527e3
environments/settings/domain/project: e13002ec4570f3fcc2f050f5ce974294
environments/settings/domain/survey_name: 169f24df0e2f42254c5458f32eb7bf00
environments/settings/domain/title: 2c3a2951a31218e8e73f07cda69ed5ff
environments/settings/enterprise/audit_logs: c7efb33d09676938d07651774b067bf6
environments/settings/enterprise/coming_soon: ee2b0671e00972773210c5be5a9ccb89
environments/settings/enterprise/contacts_and_segments: 5795d9e89c0c10e1ddbbc6fc65b8d90f
@@ -1722,6 +1732,15 @@ checksums:
environments/surveys/share/personal_links/upgrade_prompt_description: 09f394f5adf2d5a47352ceb80b43598b
environments/surveys/share/personal_links/upgrade_prompt_title: 8068b8c5b27268ec4d804a9c7d153a8f
environments/surveys/share/personal_links/work_with_segments: 45030e61077c88ae60f011b40566d5d1
environments/surveys/share/pretty_url/description: 67173cfc7010ab044428a22f5a4bdaa2
environments/surveys/share/pretty_url/remove_description: 7fb0afcb17447101ed4aa6aaffc6e136
environments/surveys/share/pretty_url/remove_success: a4a08113d3d4c53889da7c12a0674473
environments/surveys/share/pretty_url/save_success: e73e6e0adc93e0db723c765fbef7d82a
environments/surveys/share/pretty_url/slug_help: 3c81237bfb50f41b9e0525e9a2754b9d
environments/surveys/share/pretty_url/slug_label: b1cc25b6bf5032c75420aa862362808c
environments/surveys/share/pretty_url/slug_placeholder: e9266ae26c4993ef285a5b1a927e52a5
environments/surveys/share/pretty_url/slug_required: 9f1c28ff24a3507588c7c3710ae0b073
environments/surveys/share/pretty_url/title: 10a4b387b6df844245fc842be29527e3
environments/surveys/share/send_email/copy_embed_code: 04833baca1ed64dee8fc230462965d2d
environments/surveys/share/send_email/description: 875935d1d039fc5dd2de7b2a25008796
environments/surveys/share/send_email/email_preview_tab: 84458b39d3fd3bebb92de0e81d41b7da

View File

@@ -267,6 +267,7 @@ export const mockSyncSurveyOutput: SurveyMock = {
variables: [],
showLanguageSwitch: null,
metadata: {},
slug: null,
};
export const mockSurveyOutput: SurveyMock = {
@@ -290,6 +291,7 @@ export const mockSurveyOutput: SurveyMock = {
variables: [],
showLanguageSwitch: null,
...baseSurveyProperties,
slug: null,
};
export const createSurveyInput: TSurveyCreateInput = {
@@ -319,6 +321,7 @@ export const updateSurveyInput: TSurvey = {
followUps: [],
...baseSurveyProperties,
...commonMockProperties,
slug: null,
};
export const mockTransformedSurveyOutput = {

View File

@@ -108,6 +108,7 @@ export const selectSurvey = {
},
},
followUps: true,
slug: true,
} satisfies Prisma.SurveySelect;
export const checkTriggersValidity = (triggers: TSurvey["triggers"], actionClasses: ActionClass[]) => {

View File

@@ -84,9 +84,12 @@ describe("Helper Utilities", () => {
validationErrors: {
name: { _errors: ["Name is required"] },
email: { _errors: ["Email is invalid"] },
password: { _errors: ["is too short"] },
},
};
expect(getFormattedErrorMessage(result)).toBe("nameName is required\nemailEmail is invalid");
expect(getFormattedErrorMessage(result)).toBe(
"Name is required\nEmail is invalid\npassword: is too short"
);
});
test("returns empty string for undefined errors", () => {

View File

@@ -27,7 +27,12 @@ export const getFormattedErrorMessage = (result): string => {
message = Object.keys(errors || {})
.map((key) => {
if (key === "_errors") return errors[key].join(", ");
return `${key ? `${key}` : ""}${errors?.[key]?._errors?.join(", ")}`;
const fieldError = errors?.[key]?._errors?.join(", ");
if (key && fieldError?.toLowerCase().startsWith(key.toLowerCase())) {
return fieldError;
}
const keyPrefix = key ? `${key}: ` : "";
return `${keyPrefix}${fieldError}`;
})
.join("\n");
}

View File

@@ -187,6 +187,7 @@
"delete": "Löschen",
"description": "Beschreibung",
"dev_env": "Entwicklungsumgebung",
"development": "Entwicklung",
"development_environment_banner": "Du bist in einer Entwicklungsumgebung. Richte sie ein, um Umfragen, Aktionen und Attribute zu testen.",
"disable": "Deaktivieren",
"disallow": "Nicht erlauben",
@@ -194,6 +195,7 @@
"dismissed": "Entlassen",
"docs": "Dokumentation",
"documentation": "Dokumentation",
"domain": "Domain",
"download": "Herunterladen",
"draft": "Entwurf",
"duplicate": "Duplikat",
@@ -203,6 +205,7 @@
"ending_card": "Abschluss-Karte",
"enter_url": "URL eingeben",
"enterprise_license": "Enterprise Lizenz",
"environment": "Umgebung",
"environment_not_found": "Umgebung nicht gefunden",
"environment_notice": "Du befindest dich derzeit in der {environment}-Umgebung.",
"error": "Fehler",
@@ -321,6 +324,7 @@
"preview_survey": "Umfragevorschau",
"privacy": "Datenschutz",
"product_manager": "Produktmanager",
"production": "Produktion",
"profile": "Profil",
"profile_id": "Profil-ID",
"progress": "Fortschritt",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Betriebszeit SLA (99%)",
"website_surveys": "Website-Umfragen"
},
"domain": {
"description": "Übersicht aller Umfragen mit Pretty URLs in Ihrer Organisation",
"no_pretty_urls": "Noch keine Umfragen mit Pretty URLs konfiguriert.",
"pretty_url": "Pretty URL",
"project": "Projekt",
"survey_name": "Umfragename",
"title": "Pretty URLs"
},
"enterprise": {
"audit_logs": "Audit Logs",
"coming_soon": "Kommt bald",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Verwende persönliche Links mit einem höheren Plan",
"work_with_segments": "Persönliche Links funktionieren mit Segmenten."
},
"pretty_url": {
"description": "Erstellen Sie eine individuelle, einprägsame URL für Ihre Umfrage",
"remove_description": "Ihre Umfrage bleibt über die ursprüngliche URL weiterhin erreichbar.",
"remove_success": "Pretty URL erfolgreich entfernt",
"save_success": "Pretty URL erfolgreich gespeichert",
"slug_help": "Verwenden Sie nur Kleinbuchstaben, Zahlen und Bindestriche.",
"slug_label": "Individueller Slug",
"slug_placeholder": "kunden-feedback",
"slug_required": "Bitte geben Sie einen gültigen Slug ein",
"title": "Pretty URL"
},
"send_email": {
"copy_embed_code": "Einbettungscode kopieren",
"description": "Binden Sie Ihre Umfrage in eine E-Mail ein, um Antworten von Ihrem Publikum zu erhalten.",

View File

@@ -187,6 +187,7 @@
"delete": "Delete",
"description": "Description",
"dev_env": "Dev Environment",
"development": "Development",
"development_environment_banner": "You're in a development environment. Set it up to test surveys, actions and attributes.",
"disable": "Disable",
"disallow": "Don't allow",
@@ -194,6 +195,7 @@
"dismissed": "Dismissed",
"docs": "Documentation",
"documentation": "Documentation",
"domain": "Domain",
"download": "Download",
"draft": "Draft",
"duplicate": "Duplicate",
@@ -203,6 +205,7 @@
"ending_card": "Ending card",
"enter_url": "Enter URL",
"enterprise_license": "Enterprise License",
"environment": "Environment",
"environment_not_found": "Environment not found",
"environment_notice": "You're currently in the {environment} environment.",
"error": "Error",
@@ -321,6 +324,7 @@
"preview_survey": "Preview Survey",
"privacy": "Privacy Policy",
"product_manager": "Product Manager",
"production": "Production",
"profile": "Profile",
"profile_id": "Profile ID",
"progress": "Progress",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Uptime SLA (99%)",
"website_surveys": "Website Surveys"
},
"domain": {
"description": "Overview of all surveys using Pretty URLs across your organization",
"no_pretty_urls": "No surveys with Pretty URLs configured yet.",
"pretty_url": "Pretty URL",
"project": "Project",
"survey_name": "Survey Name",
"title": "Pretty URLs"
},
"enterprise": {
"audit_logs": "Audit Logs",
"coming_soon": "Coming soon",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Use personal links with a higher plan",
"work_with_segments": "Personal links work with segments."
},
"pretty_url": {
"description": "Create a custom, memorable URL for your survey",
"remove_description": "Your survey will still be accessible via its original URL.",
"remove_success": "Pretty URL removed successfully",
"save_success": "Pretty URL saved successfully",
"slug_help": "Use lowercase letters, numbers, and hyphens only.",
"slug_label": "Custom Slug",
"slug_placeholder": "customer-feedback",
"slug_required": "Please enter a valid slug",
"title": "Pretty URL"
},
"send_email": {
"copy_embed_code": "Copy embed code",
"description": "Embed your survey in an email to get responses from your audience.",

View File

@@ -187,6 +187,7 @@
"delete": "Eliminar",
"description": "Descripción",
"dev_env": "Entorno de desarrollo",
"development": "Desarrollo",
"development_environment_banner": "Estás en un entorno de desarrollo. Configúralo para probar encuestas, acciones y atributos.",
"disable": "Desactivar",
"disallow": "No permitir",
@@ -194,6 +195,7 @@
"dismissed": "Descartado",
"docs": "Documentación",
"documentation": "Documentación",
"domain": "Dominio",
"download": "Descargar",
"draft": "Borrador",
"duplicate": "Duplicar",
@@ -203,6 +205,7 @@
"ending_card": "Tarjeta final",
"enter_url": "Introducir URL",
"enterprise_license": "Licencia empresarial",
"environment": "Entorno",
"environment_not_found": "Entorno no encontrado",
"environment_notice": "Actualmente estás en el entorno {environment}.",
"error": "Error",
@@ -321,6 +324,7 @@
"preview_survey": "Vista previa de la encuesta",
"privacy": "Política de privacidad",
"product_manager": "Gestor de producto",
"production": "Producción",
"profile": "Perfil",
"profile_id": "ID de perfil",
"progress": "Progreso",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Acuerdo de nivel de servicio de tiempo de actividad (99 %)",
"website_surveys": "Encuestas de sitio web"
},
"domain": {
"description": "Resumen de todas las encuestas que utilizan URL bonitas en tu organización",
"no_pretty_urls": "Aún no hay encuestas con URL bonitas configuradas.",
"pretty_url": "URL bonita",
"project": "Proyecto",
"survey_name": "Nombre de la encuesta",
"title": "URL bonitas"
},
"enterprise": {
"audit_logs": "Registros de auditoría",
"coming_soon": "Próximamente",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Usa enlaces personales con un plan superior",
"work_with_segments": "Los enlaces personales funcionan con segmentos."
},
"pretty_url": {
"description": "Crea una URL personalizada y memorable para tu encuesta",
"remove_description": "Tu encuesta seguirá siendo accesible a través de su URL original.",
"remove_success": "URL bonita eliminada correctamente",
"save_success": "URL bonita guardada correctamente",
"slug_help": "Usa solo letras minúsculas, números y guiones.",
"slug_label": "Slug personalizado",
"slug_placeholder": "comentarios-clientes",
"slug_required": "Por favor, introduce un slug válido",
"title": "URL bonita"
},
"send_email": {
"copy_embed_code": "Copiar código de inserción",
"description": "Inserta tu encuesta en un correo electrónico para obtener respuestas de tu audiencia.",

View File

@@ -187,6 +187,7 @@
"delete": "Supprimer",
"description": "Description",
"dev_env": "Environnement de développement",
"development": "Développement",
"development_environment_banner": "Vous êtes dans un environnement de développement. Configurez-le pour tester des enquêtes, des actions et des attributs.",
"disable": "Désactiver",
"disallow": "Ne pas autoriser",
@@ -194,6 +195,7 @@
"dismissed": "Rejeté",
"docs": "Documentation",
"documentation": "Documentation",
"domain": "Domaine",
"download": "Télécharger",
"draft": "Brouillon",
"duplicate": "Dupliquer",
@@ -203,6 +205,7 @@
"ending_card": "Carte de fin",
"enter_url": "Saisir l'URL",
"enterprise_license": "Licence d'entreprise",
"environment": "Environnement",
"environment_not_found": "Environnement non trouvé",
"environment_notice": "Vous êtes actuellement dans l'environnement {environment}.",
"error": "Erreur",
@@ -321,6 +324,7 @@
"preview_survey": "Aperçu de l'enquête",
"privacy": "Politique de confidentialité",
"product_manager": "Chef de produit",
"production": "Production",
"profile": "Profil",
"profile_id": "Identifiant de profil",
"progress": "Progression",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Disponibilité de 99 %",
"website_surveys": "Sondages de site web"
},
"domain": {
"description": "Aperçu de toutes les enquêtes utilisant des URL personnalisées dans votre organisation",
"no_pretty_urls": "Aucune enquête avec URL personnalisée configurée pour le moment.",
"pretty_url": "URL personnalisée",
"project": "Projet",
"survey_name": "Nom de l'enquête",
"title": "URL personnalisées"
},
"enterprise": {
"audit_logs": "Journaux d'audit",
"coming_soon": "À venir bientôt",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Utilisez des liens personnels avec un plan supérieur",
"work_with_segments": "Les liens personnels fonctionnent avec les segments."
},
"pretty_url": {
"description": "Créez une URL personnalisée et mémorable pour votre enquête",
"remove_description": "Votre enquête restera accessible via son URL d'origine.",
"remove_success": "URL personnalisée supprimée avec succès",
"save_success": "URL personnalisée enregistrée avec succès",
"slug_help": "Utilisez uniquement des lettres minuscules, des chiffres et des tirets.",
"slug_label": "Slug personnalisé",
"slug_placeholder": "retour-client",
"slug_required": "Veuillez saisir un slug valide",
"title": "URL personnalisée"
},
"send_email": {
"copy_embed_code": "Copier le code d'intégration",
"description": "Intégrez votre sondage dans un email pour obtenir des réponses de votre audience.",

View File

@@ -187,6 +187,7 @@
"delete": "削除",
"description": "説明",
"dev_env": "開発環境",
"development": "開発",
"development_environment_banner": "あなたは開発環境にいます。フォーム、アクション、属性をテストするように設定してください。",
"disable": "無効にする",
"disallow": "許可しない",
@@ -194,6 +195,7 @@
"dismissed": "非表示",
"docs": "ドキュメント",
"documentation": "ドキュメント",
"domain": "ドメイン",
"download": "ダウンロード",
"draft": "下書き",
"duplicate": "複製",
@@ -203,6 +205,7 @@
"ending_card": "終了カード",
"enter_url": "URLを入力",
"enterprise_license": "エンタープライズライセンス",
"environment": "環境",
"environment_not_found": "環境が見つかりません",
"environment_notice": "現在、{environment} 環境にいます。",
"error": "エラー",
@@ -321,6 +324,7 @@
"preview_survey": "フォームをプレビュー",
"privacy": "プライバシーポリシー",
"product_manager": "プロダクトマネージャー",
"production": "本番",
"profile": "プロフィール",
"profile_id": "プロフィールID",
"progress": "進捗",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "稼働率SLA (99%)",
"website_surveys": "ウェブサイトフォーム"
},
"domain": {
"description": "組織全体でカスタムURLを使用しているすべてのフォームの概要",
"no_pretty_urls": "カスタムURLが設定されたフォームはまだありません。",
"pretty_url": "カスタムURL",
"project": "プロジェクト",
"survey_name": "フォーム名",
"title": "カスタムURL"
},
"enterprise": {
"audit_logs": "監査ログ",
"coming_soon": "近日公開",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "上位プランで個人リンクを使用",
"work_with_segments": "個人リンクはセグメントで機能します。"
},
"pretty_url": {
"description": "フォーム用のカスタムで覚えやすいURLを作成",
"remove_description": "フォームは元のURLから引き続きアクセス可能です。",
"remove_success": "カスタムURLを削除しました",
"save_success": "カスタムURLを保存しました",
"slug_help": "小文字のアルファベット、数字、ハイフンのみ使用できます。",
"slug_label": "カスタムスラッグ",
"slug_placeholder": "customer-feedback",
"slug_required": "有効なスラッグを入力してください",
"title": "カスタムURL"
},
"send_email": {
"copy_embed_code": "埋め込みコードをコピー",
"description": "メールにフォームを埋め込んで、オーディエンスから回答を得ます。",

View File

@@ -187,6 +187,7 @@
"delete": "Verwijderen",
"description": "Beschrijving",
"dev_env": "Ontwikkelomgeving",
"development": "Ontwikkeling",
"development_environment_banner": "Je bevindt je in een ontwikkelomgeving. Stel het in om enquêtes, acties en attributen te testen.",
"disable": "Uitzetten",
"disallow": "Niet toestaan",
@@ -194,6 +195,7 @@
"dismissed": "Afgewezen",
"docs": "Documentatie",
"documentation": "Documentatie",
"domain": "Domein",
"download": "Downloaden",
"draft": "Voorlopige versie",
"duplicate": "Duplicaat",
@@ -203,6 +205,7 @@
"ending_card": "Einde kaart",
"enter_url": "URL invoeren",
"enterprise_license": "Enterprise-licentie",
"environment": "Omgeving",
"environment_not_found": "Omgeving niet gevonden",
"environment_notice": "U bevindt zich momenteel in de {environment}-omgeving.",
"error": "Fout",
@@ -321,6 +324,7 @@
"preview_survey": "Voorbeeld van enquête",
"privacy": "Privacybeleid",
"product_manager": "Productmanager",
"production": "Productie",
"profile": "Profiel",
"profile_id": "Profiel-ID",
"progress": "Voortgang",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Uptime-SLA (99%)",
"website_surveys": "Website-enquêtes"
},
"domain": {
"description": "Overzicht van alle enquêtes die Pretty URL's gebruiken binnen je organisatie",
"no_pretty_urls": "Nog geen enquêtes met Pretty URL's geconfigureerd.",
"pretty_url": "Pretty URL",
"project": "Project",
"survey_name": "Enquêtenaam",
"title": "Pretty URL's"
},
"enterprise": {
"audit_logs": "Auditlogboeken",
"coming_soon": "Binnenkort beschikbaar",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Gebruik persoonlijke links met een hoger plan",
"work_with_segments": "Persoonlijke links werken met segmenten."
},
"pretty_url": {
"description": "Maak een aangepaste, memorabele URL voor je enquête",
"remove_description": "Je enquête blijft toegankelijk via de oorspronkelijke URL.",
"remove_success": "Pretty URL succesvol verwijderd",
"save_success": "Pretty URL succesvol opgeslagen",
"slug_help": "Gebruik alleen kleine letters, cijfers en koppeltekens.",
"slug_label": "Aangepaste slug",
"slug_placeholder": "klantfeedback",
"slug_required": "Voer een geldige slug in",
"title": "Pretty URL"
},
"send_email": {
"copy_embed_code": "Kopieer de insluitcode",
"description": "Sluit uw enquête in een e-mail in om reacties van uw publiek te krijgen.",

View File

@@ -187,6 +187,7 @@
"delete": "Apagar",
"description": "Descrição",
"dev_env": "Ambiente de Desenvolvimento",
"development": "Desenvolvimento",
"development_environment_banner": "Você está em um ambiente de desenvolvimento. Configure-o para testar pesquisas, ações e atributos.",
"disable": "desativar",
"disallow": "Não permita",
@@ -194,6 +195,7 @@
"dismissed": "Dispensado",
"docs": "Documentação",
"documentation": "Documentação",
"domain": "Domínio",
"download": "baixar",
"draft": "Rascunho",
"duplicate": "Duplicar",
@@ -203,6 +205,7 @@
"ending_card": "Cartão de encerramento",
"enter_url": "Inserir URL",
"enterprise_license": "Licença Empresarial",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Você está atualmente no ambiente {environment}.",
"error": "Erro",
@@ -321,6 +324,7 @@
"preview_survey": "Prévia da Pesquisa",
"privacy": "Política de Privacidade",
"product_manager": "Gerente de Produto",
"production": "Produção",
"profile": "Perfil",
"profile_id": "ID de Perfil",
"progress": "Progresso",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Tempo de atividade SLA (99%)",
"website_surveys": "Pesquisas de Site"
},
"domain": {
"description": "Visão geral de todas as pesquisas usando URLs amigáveis em sua organização",
"no_pretty_urls": "Nenhuma pesquisa com URLs amigáveis configuradas ainda.",
"pretty_url": "URL amigável",
"project": "Projeto",
"survey_name": "Nome da Pesquisa",
"title": "URLs amigáveis"
},
"enterprise": {
"audit_logs": "Registros de Auditoria",
"coming_soon": "Em breve",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Use links pessoais com um plano superior",
"work_with_segments": "Links pessoais funcionam com segmentos."
},
"pretty_url": {
"description": "Crie uma URL personalizada e memorável para sua pesquisa",
"remove_description": "Sua pesquisa ainda estará acessível através da URL original.",
"remove_success": "URL amigável removida com sucesso",
"save_success": "URL amigável salva com sucesso",
"slug_help": "Use apenas letras minúsculas, números e hífens.",
"slug_label": "Slug personalizado",
"slug_placeholder": "feedback-do-cliente",
"slug_required": "Por favor, insira um slug válido",
"title": "URL amigável"
},
"send_email": {
"copy_embed_code": "Copiar código incorporado",
"description": "Incorpore sua pesquisa em um e-mail para obter respostas do seu público.",

View File

@@ -187,6 +187,7 @@
"delete": "Eliminar",
"description": "Descrição",
"dev_env": "Ambiente de Desenvolvimento",
"development": "Desenvolvimento",
"development_environment_banner": "Está num ambiente de desenvolvimento. Configure-o para testar inquéritos, ações e atributos.",
"disable": "Desativar",
"disallow": "Não permitir",
@@ -194,6 +195,7 @@
"dismissed": "Dispensado",
"docs": "Documentação",
"documentation": "Documentação",
"domain": "Domínio",
"download": "Transferir",
"draft": "Rascunho",
"duplicate": "Duplicar",
@@ -203,6 +205,7 @@
"ending_card": "Cartão de encerramento",
"enter_url": "Introduzir URL",
"enterprise_license": "Licença Enterprise",
"environment": "Ambiente",
"environment_not_found": "Ambiente não encontrado",
"environment_notice": "Está atualmente no ambiente {environment}.",
"error": "Erro",
@@ -321,6 +324,7 @@
"preview_survey": "Pré-visualização do inquérito",
"privacy": "Política de Privacidade",
"product_manager": "Gestor de Produto",
"production": "Produção",
"profile": "Perfil",
"profile_id": "ID do Perfil",
"progress": "Progresso",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "SLA de Tempo de Atividade (99%)",
"website_surveys": "Inquéritos (site)"
},
"domain": {
"description": "Visão geral de todos os inquéritos que utilizam URLs amigáveis na sua organização",
"no_pretty_urls": "Ainda não existem inquéritos com URLs amigáveis configurados.",
"pretty_url": "URL amigável",
"project": "Projeto",
"survey_name": "Nome do inquérito",
"title": "URLs amigáveis"
},
"enterprise": {
"audit_logs": "Registos de Auditoria",
"coming_soon": "Em breve",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Utilize links pessoais com um plano superior",
"work_with_segments": "Os links pessoais funcionam com segmentos."
},
"pretty_url": {
"description": "Crie um URL personalizado e memorável para o seu inquérito",
"remove_description": "O seu inquérito continuará acessível através do URL original.",
"remove_success": "URL amigável removido com sucesso",
"save_success": "URL amigável guardado com sucesso",
"slug_help": "Use apenas letras minúsculas, números e hífenes.",
"slug_label": "Slug personalizado",
"slug_placeholder": "feedback-cliente",
"slug_required": "Por favor, introduza um slug válido",
"title": "URL amigável"
},
"send_email": {
"copy_embed_code": "Copiar código de incorporação",
"description": "Incorpora o teu inquérito num email para obter respostas do teu público.",

View File

@@ -187,6 +187,7 @@
"delete": "Șterge",
"description": "Descriere",
"dev_env": "Mediu de dezvoltare",
"development": "Dezvoltare",
"development_environment_banner": "Ești într-un mediu de dezvoltare. Configurează-l pentru a testa sondaje, acțiuni și atribute.",
"disable": "Dezactivează",
"disallow": "Nu permite",
@@ -194,6 +195,7 @@
"dismissed": "Respins",
"docs": "Documentație",
"documentation": "Documentație",
"domain": "Domeniu",
"download": "Descărcare",
"draft": "Schiță",
"duplicate": "Duplicități",
@@ -203,6 +205,7 @@
"ending_card": "Cardul de finalizare",
"enter_url": "Introduceți URL-ul",
"enterprise_license": "Licență Întreprindere",
"environment": "Mediu",
"environment_not_found": "Mediul nu a fost găsit",
"environment_notice": "Te afli în prezent în mediul {environment}",
"error": "Eroare",
@@ -321,6 +324,7 @@
"preview_survey": "Previzualizare Chestionar",
"privacy": "Politica de Confidențialitate",
"product_manager": "Manager de Produs",
"production": "Producție",
"profile": "Profil",
"profile_id": "ID Profil",
"progress": "Progres",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Disponibilitate SLA (99%)",
"website_surveys": "Sondaje ale site-ului"
},
"domain": {
"description": "Prezentare generală a tuturor chestionarelor care folosesc Pretty URLs în organizația dvs.",
"no_pretty_urls": "Nu există încă chestionare cu Pretty URLs configurate.",
"pretty_url": "Pretty URL",
"project": "Proiect",
"survey_name": "Nume chestionar",
"title": "URL-uri frumoase"
},
"enterprise": {
"audit_logs": "Jurnale de audit",
"coming_soon": "În curând",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Folosește linkuri personale cu un plan superior",
"work_with_segments": "Linkuri personale funcționează cu segmente."
},
"pretty_url": {
"description": "Creează o adresă URL personalizată și ușor de reținut pentru chestionarul tău",
"remove_description": "Chestionarul va fi în continuare accesibil prin URL-ul original.",
"remove_success": "Pretty URL a fost eliminat cu succes",
"save_success": "Pretty URL a fost salvat cu succes",
"slug_help": "Folosiți doar litere mici, cifre și cratime.",
"slug_label": "Slug personalizat",
"slug_placeholder": "customer-feedback",
"slug_required": "Vă rugăm să introduceți un slug valid",
"title": "Pretty URL"
},
"send_email": {
"copy_embed_code": "Copiază codul de inserare",
"description": "Inserați sondajul dvs. într-un e-mail pentru a obține răspunsuri de la audiența dvs.",

View File

@@ -187,6 +187,7 @@
"delete": "Удалить",
"description": "Описание",
"dev_env": "Dev Environment",
"development": "Разработка",
"development_environment_banner": "Вы находитесь в среде разработки. Настройте её для тестирования опросов, действий и атрибутов.",
"disable": "Отключить",
"disallow": "Не разрешать",
@@ -194,6 +195,7 @@
"dismissed": "Отклонено",
"docs": "Документация",
"documentation": "Документация",
"domain": "Домен",
"download": "Скачать",
"draft": "Черновик",
"duplicate": "Дублировать",
@@ -203,6 +205,7 @@
"ending_card": "Завершающая карточка",
"enter_url": "Введите URL",
"enterprise_license": "Корпоративная лицензия",
"environment": "Окружение",
"environment_not_found": "Среда не найдена",
"environment_notice": "В данный момент вы находитесь в среде {environment}.",
"error": "Ошибка",
@@ -321,6 +324,7 @@
"preview_survey": "Предпросмотр опроса",
"privacy": "Политика конфиденциальности",
"product_manager": "Менеджер продукта",
"production": "Продакшн",
"profile": "Профиль",
"profile_id": "ID профиля",
"progress": "Прогресс",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "SLA по времени безотказной работы (99%)",
"website_surveys": "Опросы на сайте"
},
"domain": {
"description": "Обзор всех опросов с Pretty URL в вашей организации",
"no_pretty_urls": "Пока не настроено ни одного опроса с Pretty URL.",
"pretty_url": "Pretty URL",
"project": "Проект",
"survey_name": "Название опроса",
"title": "Pretty URL"
},
"enterprise": {
"audit_logs": "Журналы аудита",
"coming_soon": "Скоро будет",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Используйте персональные ссылки на более высоком тарифе",
"work_with_segments": "Персональные ссылки работают с сегментами."
},
"pretty_url": {
"description": "Создайте индивидуальный, запоминающийся URL для вашего опроса",
"remove_description": "Ваш опрос по-прежнему будет доступен по исходному URL.",
"remove_success": "Pretty URL успешно удалён",
"save_success": "Pretty URL успешно сохранён",
"slug_help": "Используйте только строчные буквы, цифры и дефисы.",
"slug_label": "Пользовательский слаг",
"slug_placeholder": "customer-feedback",
"slug_required": "Пожалуйста, введите корректный слаг",
"title": "Pretty URL"
},
"send_email": {
"copy_embed_code": "Скопировать код для вставки",
"description": "Вставьте ваш опрос в электронное письмо, чтобы получить ответы от вашей аудитории.",

View File

@@ -187,6 +187,7 @@
"delete": "Ta bort",
"description": "Beskrivning",
"dev_env": "Utvecklingsmiljö",
"development": "Utveckling",
"development_environment_banner": "Du är i en utvecklingsmiljö. Konfigurera den för att testa enkäter, åtgärder och attribut.",
"disable": "Inaktivera",
"disallow": "Tillåt inte",
@@ -194,6 +195,7 @@
"dismissed": "Avvisad",
"docs": "Dokumentation",
"documentation": "Dokumentation",
"domain": "Domän",
"download": "Ladda ner",
"draft": "Utkast",
"duplicate": "Duplicera",
@@ -203,6 +205,7 @@
"ending_card": "Avslutningskort",
"enter_url": "Ange URL",
"enterprise_license": "Företagslicens",
"environment": "Miljö",
"environment_not_found": "Miljö hittades inte",
"environment_notice": "Du är för närvarande i {environment}-miljön.",
"error": "Fel",
@@ -321,6 +324,7 @@
"preview_survey": "Förhandsgranska enkät",
"privacy": "Integritetspolicy",
"product_manager": "Produktchef",
"production": "Produktion",
"profile": "Profil",
"profile_id": "Profil-ID",
"progress": "Framsteg",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "Drifttids-SLA (99%)",
"website_surveys": "Webbplatsenkäter"
},
"domain": {
"description": "Översikt över alla enkäter som använder Pretty URLs i din organisation",
"no_pretty_urls": "Inga enkäter med Pretty URLs har konfigurerats ännu.",
"pretty_url": "Pretty URL",
"project": "Projekt",
"survey_name": "Enkätnamn",
"title": "Pretty URL:er"
},
"enterprise": {
"audit_logs": "Granskningsloggar",
"coming_soon": "Kommer snart",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "Använd personliga länkar med en högre plan",
"work_with_segments": "Personliga länkar fungerar med segment."
},
"pretty_url": {
"description": "Skapa en anpassad, minnesvärd URL för din enkät",
"remove_description": "Din enkät kommer fortfarande att vara tillgänglig via sin ursprungliga URL.",
"remove_success": "Pretty URL har tagits bort",
"save_success": "Pretty URL har sparats",
"slug_help": "Använd endast små bokstäver, siffror och bindestreck.",
"slug_label": "Anpassad slug",
"slug_placeholder": "customer-feedback",
"slug_required": "Ange en giltig slug",
"title": "Pretty URL"
},
"send_email": {
"copy_embed_code": "Kopiera inbäddningskod",
"description": "Bädda in din enkät i ett e-postmeddelande för att få svar från din målgrupp.",

View File

@@ -187,6 +187,7 @@
"delete": "删除",
"description": "描述",
"dev_env": "开发 环境",
"development": "开发环境",
"development_environment_banner": "您 在 开发 环境 中。 设置 以 测试 调查 操作 和 属性。",
"disable": "禁用",
"disallow": "不允许",
@@ -194,6 +195,7 @@
"dismissed": "忽略",
"docs": "文档",
"documentation": "文档",
"domain": "域名",
"download": "下载",
"draft": "草稿",
"duplicate": "复制",
@@ -203,6 +205,7 @@
"ending_card": "结尾卡片",
"enter_url": "输入 URL",
"enterprise_license": "企业 许可证",
"environment": "环境",
"environment_not_found": "环境 未找到",
"environment_notice": "你 目前 位于 {environment} 环境。",
"error": "错误",
@@ -321,6 +324,7 @@
"preview_survey": "预览 Survey",
"privacy": "隐私政策",
"product_manager": "产品经理",
"production": "生产环境",
"profile": "资料",
"profile_id": "资料 ID",
"progress": "进度",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "正常运行时间 SLA (99%)",
"website_surveys": "网站 调查"
},
"domain": {
"description": "概览组织内所有使用美化 URL 的调查问卷",
"no_pretty_urls": "尚未配置任何美化 URL 的调查问卷。",
"pretty_url": "美化 URL",
"project": "项目",
"survey_name": "调查名称",
"title": "美化 URL"
},
"enterprise": {
"audit_logs": "审计日志",
"coming_soon": "即将推出",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "在更高的计划中使用 个人 链接",
"work_with_segments": "个人链接与片段一起工作。"
},
"pretty_url": {
"description": "为您的调查创建一个自定义且易记的 URL",
"remove_description": "您的调查仍可通过原始 URL 访问。",
"remove_success": "美化 URL 移除成功",
"save_success": "美化 URL 保存成功",
"slug_help": "仅可使用小写字母、数字和连字符。",
"slug_label": "自定义短链",
"slug_placeholder": "customer-feedback",
"slug_required": "请输入有效的短链",
"title": "美化 URL"
},
"send_email": {
"copy_embed_code": "复制 嵌入代码",
"description": "将你的调查嵌入到电子邮件中,以获取你所在受众的回复。",

View File

@@ -187,6 +187,7 @@
"delete": "刪除",
"description": "描述",
"dev_env": "開發環境",
"development": "開發",
"development_environment_banner": "您正在開發環境中。設定它以測試問卷、操作和屬性。",
"disable": "停用",
"disallow": "不允許",
@@ -194,6 +195,7 @@
"dismissed": "已關閉",
"docs": "文件",
"documentation": "文件",
"domain": "網域",
"download": "下載",
"draft": "草稿",
"duplicate": "複製",
@@ -203,6 +205,7 @@
"ending_card": "結尾卡片",
"enter_url": "輸入 URL",
"enterprise_license": "企業授權",
"environment": "環境",
"environment_not_found": "找不到環境",
"environment_notice": "您目前在 '{'environment'}' 環境中。",
"error": "錯誤",
@@ -321,6 +324,7 @@
"preview_survey": "預覽問卷",
"privacy": "隱私權政策",
"product_manager": "產品經理",
"production": "正式環境",
"profile": "個人資料",
"profile_id": "個人資料 ID",
"progress": "進度",
@@ -1026,6 +1030,14 @@
"uptime_sla_99": "正常運作時間 SLA (99%)",
"website_surveys": "網站問卷"
},
"domain": {
"description": "檢視貴組織內所有使用 Pretty URL 的問卷",
"no_pretty_urls": "尚未設定任何使用 Pretty URL 的問卷。",
"pretty_url": "Pretty URL",
"project": "專案",
"survey_name": "問卷名稱",
"title": "美化網址"
},
"enterprise": {
"audit_logs": "稽核記錄",
"coming_soon": "即將推出",
@@ -1825,6 +1837,17 @@
"upgrade_prompt_title": "使用 個人 連結 與 更高 的 計劃",
"work_with_segments": "個人 連結 可 與 分段 一起 使用"
},
"pretty_url": {
"description": "為您的問卷建立自訂且易記的網址",
"remove_description": "您的問卷仍可透過原始網址存取。",
"remove_success": "Pretty URL 已成功移除",
"save_success": "Pretty URL 已成功儲存",
"slug_help": "僅可使用小寫字母、數字和連字號。",
"slug_label": "自訂網址代稱",
"slug_placeholder": "customer-feedback",
"slug_required": "請輸入有效的代稱",
"title": "Pretty URL"
},
"send_email": {
"copy_embed_code": "複製嵌入程式碼",
"description": "將 你的 調查 嵌入 在 電子郵件 中 以 獲得 觀眾 的 回應。",

View File

@@ -10,7 +10,7 @@ import { MembersView } from "@/modules/organization/settings/teams/components/me
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
import { PageHeader } from "@/modules/ui/components/page-header";
export const TeamsPage = async (props) => {
export const TeamsPage = async (props: { params: Promise<{ environmentId: string }> }) => {
const params = await props.params;
const t = await getTranslate();

View File

@@ -0,0 +1,160 @@
import { Prisma } from "@prisma/client";
import { afterEach, describe, expect, test, vi } from "vitest";
import { prisma } from "@formbricks/database";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { getSurveyBySlug, getSurveysWithSlugsByOrganizationId, updateSurveySlug } from "./slug";
// Mock prisma
vi.mock("@formbricks/database", () => ({
prisma: {
survey: {
findUnique: vi.fn(),
findMany: vi.fn(),
update: vi.fn(),
},
},
}));
describe("Slug Library Tests", () => {
afterEach(() => {
vi.clearAllMocks();
});
describe("getSurveyBySlug", () => {
test("should return survey when found", async () => {
const mockSurvey = { id: "survey_123", environmentId: "env_123", status: "inProgress" };
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(mockSurvey as any);
const result = await getSurveyBySlug("test-slug");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSurvey);
}
expect(prisma.survey.findUnique).toHaveBeenCalledWith({
where: { slug: "test-slug" },
select: { id: true, environmentId: true, status: true },
});
});
test("should return null when survey not found", async () => {
vi.mocked(prisma.survey.findUnique).mockResolvedValueOnce(null);
const result = await getSurveyBySlug("nonexistent");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toBeNull();
}
});
test("should return DatabaseError when prisma fails", async () => {
vi.mocked(prisma.survey.findUnique).mockRejectedValueOnce(new Error("DB error"));
const result = await getSurveyBySlug("error-slug");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(DatabaseError);
expect(result.error.message).toBe("DB error");
}
});
});
describe("updateSurveySlug", () => {
test("should update slug successfully", async () => {
const mockResult = { id: "survey_123", slug: "new-slug" };
vi.mocked(prisma.survey.update).mockResolvedValueOnce(mockResult as any);
const result = await updateSurveySlug("survey_123", "new-slug");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockResult);
}
expect(prisma.survey.update).toHaveBeenCalledWith({
where: { id: "survey_123" },
data: { slug: "new-slug" },
select: { id: true, slug: true },
});
});
test("should return UniqueConstraintError on P2002", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Conflict", {
code: "P2002",
clientVersion: "0.0.1",
});
vi.mocked(prisma.survey.update).mockRejectedValueOnce(prismaError);
const result = await updateSurveySlug("survey_123", "taken-slug");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(UniqueConstraintError);
}
});
test("should return ResourceNotFoundError on P2025", async () => {
const prismaError = new Prisma.PrismaClientKnownRequestError("Not found", {
code: "P2025",
clientVersion: "0.0.1",
});
vi.mocked(prisma.survey.update).mockRejectedValueOnce(prismaError);
const result = await updateSurveySlug("nonexistent", "new-slug");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(ResourceNotFoundError);
}
});
test("should return DatabaseError on other prisma errors", async () => {
vi.mocked(prisma.survey.update).mockRejectedValueOnce(new Error("Unexpected error"));
const result = await updateSurveySlug("survey_123", "new-slug");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(DatabaseError);
}
});
});
describe("getSurveysWithSlugsByOrganizationId", () => {
test("should return surveys with slugs", async () => {
const mockSurveys = [
{
id: "survey_1",
name: "Survey 1",
slug: "slug-1",
status: "inProgress",
createdAt: new Date(),
environment: {
id: "env_1",
type: "production",
project: { id: "proj_1", name: "Project 1" },
},
},
];
vi.mocked(prisma.survey.findMany).mockResolvedValueOnce(mockSurveys as any);
const result = await getSurveysWithSlugsByOrganizationId("org_123");
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data).toEqual(mockSurveys);
}
expect(prisma.survey.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
slug: { not: null },
environment: { project: { organizationId: "org_123" } },
},
})
);
});
test("should return DatabaseError when prisma fails", async () => {
vi.mocked(prisma.survey.findMany).mockRejectedValueOnce(new Error("DB error"));
const result = await getSurveysWithSlugsByOrganizationId("org_123");
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toBeInstanceOf(DatabaseError);
}
});
});
});

View File

@@ -0,0 +1,106 @@
import "server-only";
import { Prisma } from "@prisma/client";
import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
import { Result, err, ok } from "@formbricks/types/error-handlers";
import { DatabaseError, ResourceNotFoundError, UniqueConstraintError } from "@formbricks/types/errors";
import { TSurveyStatus } from "@formbricks/types/surveys/types";
export interface TSurveyBySlug {
id: string;
environmentId: string;
status: string;
}
export interface TSurveyWithSlug {
id: string;
name: string;
slug: string | null;
status: TSurveyStatus;
createdAt: Date;
environment: {
id: string;
type: "production" | "development";
project: {
id: string;
name: string;
};
};
}
// Find a survey by its slug
export const getSurveyBySlug = reactCache(
async (slug: string): Promise<Result<TSurveyBySlug | null, DatabaseError>> => {
try {
const survey = await prisma.survey.findUnique({
where: { slug },
select: { id: true, environmentId: true, status: true },
});
return ok(survey);
} catch (error) {
return err(new DatabaseError(error.message));
}
}
);
// Update a survey's slug
export const updateSurveySlug = async (
surveyId: string,
slug: string | null
): Promise<
Result<{ id: string; slug: string | null }, UniqueConstraintError | DatabaseError | ResourceNotFoundError>
> => {
try {
const result = await prisma.survey.update({
where: { id: surveyId },
data: { slug },
select: { id: true, slug: true },
});
return ok(result);
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2002") {
return err(new UniqueConstraintError("Survey with this slug already exists"));
}
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
return err(new ResourceNotFoundError("Survey", surveyId));
}
return err(new DatabaseError(error.message));
}
};
// Get all surveys with slugs for an organization (for Domain settings page)
export const getSurveysWithSlugsByOrganizationId = reactCache(
async (organizationId: string): Promise<Result<TSurveyWithSlug[], DatabaseError>> => {
try {
const surveys = await prisma.survey.findMany({
where: {
slug: { not: null },
environment: {
project: { organizationId },
},
},
select: {
id: true,
name: true,
slug: true,
status: true,
createdAt: true,
environment: {
select: {
id: true,
type: true,
project: {
select: { id: true, name: true },
},
},
},
},
});
return ok(surveys);
} catch (error) {
return err(new DatabaseError(error.message));
}
}
);

View File

@@ -39,6 +39,7 @@ export const selectSurvey = {
recaptcha: true,
isBackButtonHidden: true,
metadata: true,
slug: true,
languages: {
select: {
default: true,

View File

@@ -0,0 +1,67 @@
"use server";
import { z } from "zod";
import { OperationNotAllowedError } from "@formbricks/types/errors";
import { ZSurveySlug } from "@formbricks/types/surveys/types";
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
import { authenticatedActionClient } from "@/lib/utils/action-client";
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
import { getOrganizationIdFromSurveyId, getProjectIdFromSurveyId } from "@/lib/utils/helper";
import { updateSurveySlug } from "@/modules/survey/lib/slug";
const ZUpdateSurveySlugAction = z.object({
surveyId: z.string().cuid2(),
slug: ZSurveySlug,
});
export const updateSurveySlugAction = authenticatedActionClient
.schema(ZUpdateSurveySlugAction)
.action(async ({ ctx, parsedInput }) => {
if (IS_FORMBRICKS_CLOUD) {
throw new OperationNotAllowedError("Pretty URLs are only available on self-hosted instances");
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
return await updateSurveySlug(parsedInput.surveyId, parsedInput.slug);
});
const ZRemoveSurveySlugAction = z.object({
surveyId: z.string().cuid2(),
});
export const removeSurveySlugAction = authenticatedActionClient
.schema(ZRemoveSurveySlugAction)
.action(async ({ ctx, parsedInput }) => {
if (IS_FORMBRICKS_CLOUD) {
throw new OperationNotAllowedError("Pretty URLs are only available on self-hosted instances");
}
const organizationId = await getOrganizationIdFromSurveyId(parsedInput.surveyId);
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId,
access: [
{ type: "organization", roles: ["owner", "manager"] },
{
type: "projectTeam",
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
minPermission: "readWrite",
},
],
});
return await updateSurveySlug(parsedInput.surveyId, null);
});

View File

@@ -42,4 +42,5 @@ export const getMinimalSurvey = (t: TFunction): TSurvey => ({
followUps: [],
isBackButtonHidden: false,
metadata: {},
slug: null,
});

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- A unique constraint covering the columns `[slug]` on the table `Survey` will be added. If there are existing duplicate values, this will fail.
*/
-- AlterTable
ALTER TABLE "public"."Survey" ADD COLUMN "slug" TEXT;
-- CreateIndex
CREATE UNIQUE INDEX "Survey_slug_key" ON "public"."Survey"("slug");

View File

@@ -388,6 +388,8 @@ model Survey {
/// [SurveyLinkMetadata]
metadata Json @default("{}")
slug String? @unique
@@index([environmentId, updatedAt])
@@index([segmentId])
}

View File

@@ -218,6 +218,12 @@ export const ZSurveyVariables = z.array(ZSurveyVariable);
export type TSurveyVariable = z.infer<typeof ZSurveyVariable>;
export type TSurveyVariables = z.infer<typeof ZSurveyVariables>;
export const ZSurveySlug = z
.string()
.regex(/^[a-z0-9-]+$/, "Slug can only contain lowercase letters, numbers, and hyphens");
export type TSurveySlug = z.infer<typeof ZSurveySlug>;
export const ZSurveyProjectOverwrites = z.object({
brandColor: ZColor.nullish(),
highlightBorderColor: ZColor.nullish(),
@@ -893,6 +899,7 @@ export const ZSurvey = z
displayPercentage: z.number().min(0.01).max(100).nullable(),
languages: z.array(ZSurveyLanguage),
metadata: ZSurveyMetadata,
slug: ZSurveySlug.nullable(),
})
.superRefine((survey, ctx) => {
const { questions, blocks, languages, welcomeCard, endings, isBackButtonHidden } = survey;

1
pnpm-lock.yaml generated
View File

@@ -8478,6 +8478,7 @@ packages:
next@15.5.9:
resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
deprecated: This version has a security vulnerability. Please upgrade to a patched version. See https://nextjs.org/blog/security-update-2025-12-11 for more details.
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0