Compare commits

...

23 Commits

Author SHA1 Message Date
pandeymangg
0233541287 adds security signup UI in the org settings 2025-12-25 13:23:07 +05:30
Dhruwang Jariwala
adf12f551d fix: Swedish translations (#7032) 2025-12-23 12:02:26 +00:00
Dhruwang Jariwala
3f2bddc358 feat: Russian translations (#7027) 2025-12-23 10:31:09 +00:00
Dhruwang Jariwala
ae6d1ac133 chore: improve wording in email text (Duplicate of #7003) (#7025)
Co-authored-by: Balázs Úr <balazs@urbalazs.hu>
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-23 09:56:53 +00:00
Dhruwang Jariwala
7c4569cd50 fix: file upload validation (#7028) 2025-12-23 09:36:45 +00:00
Matti Nannt
7354122447 fix: update V2 API OpenAPI paths to include full prefixes (#6983)
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-23 06:29:25 +00:00
Matti Nannt
d54dca2b27 docs: update thanks section with chromatic and sentry logos (#7031) 2025-12-22 16:40:39 +00:00
Anshuman Pandey
acd5cff534 feat: email package for client side email components (#6986) 2025-12-22 14:13:06 +00:00
Matti Nannt
834929e766 feat: configure @formbricks/survey-ui for external publishing (#6991) 2025-12-22 12:39:54 +00:00
Dhruwang Jariwala
09f40ad816 fix: required cta issue (#7022) 2025-12-22 08:35:08 +00:00
Harsh Bhat
689b6491b3 docs: Link vs In app surveys (#7006)
Co-authored-by: Johannes <johannes@formbricks.com>
2025-12-22 08:13:45 +00:00
Johannes
b70b2eef95 fix: vimeo + loom embed (#7018)
Co-authored-by: Dhruwang <dhruwangjariwala18@gmail.com>
2025-12-20 08:08:48 +00:00
Harsh Bhat
392a95834b docs: Best practices Panel Management (#7011) 2025-12-20 06:32:57 +00:00
Anshuman Pandey
66d9cc8eac chore: adds docs for min browser version support (#7014) 2025-12-19 10:02:01 +00:00
Johannes
befdc078f1 fix: replace isomorphic-dompurify with sanitize-html in server component (#7002) 2025-12-19 07:34:56 +00:00
Dhruwang Jariwala
13b983b3b2 fix: missing question media (#6997)
Co-authored-by: Johannes <johannes@formbricks.com>
Co-authored-by: pandeymangg <anshuman.pandey9999@gmail.com>
2025-12-19 07:29:06 +00:00
Harsh Bhat
1e285ebe4e docs: Remove references of delay removal with debug mode (#7009) 2025-12-19 07:03:02 +00:00
Dhruwang Jariwala
a7c4971952 fix: replaced bg-white with survey-bg color in surveys package (#7004)
Co-authored-by: Luis Gustavo S. Barreto <gustavo@ossystems.com.br>
2025-12-19 06:50:33 +00:00
Dhruwang Jariwala
c8689d91d5 fix: empty button in cta question (#6995) 2025-12-18 21:18:48 +00:00
Dhruwang Jariwala
73a2ff7421 fix: border radius for inputs (#6996) 2025-12-18 20:56:47 +00:00
Dhruwang Jariwala
0c28e89b41 fix: missing required question warning (#6998) 2025-12-18 19:12:47 +00:00
Anshuman Pandey
a736436e29 chore: fixes typo (#6993) 2025-12-18 09:25:12 +00:00
Johannes
7dbb0300d3 fix: Pass the isExternalUrlAllowed prop to welcome card (#6992) 2025-12-18 08:51:21 +00:00
152 changed files with 10465 additions and 3731 deletions

View File

@@ -13,13 +13,12 @@ jobs:
chromatic:
name: Run Chromatic
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
packages: write
id-token: write
actions: read
contents: read
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@0634a2670c59f64b4a01f0f96f84700a4088b9f0 # v2.12.0
uses: step-security/harden-runner@ec9f2d5744a09debf3a187a3f4f675c53b671911 # v2.13.0
with:
egress-policy: audit
@@ -27,16 +26,34 @@ jobs:
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with:
fetch-depth: 0
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.2.0
- name: Setup Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 20
- name: Install pnpm
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- name: Setup pnpm cache
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4.2.3
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --config.platform=linux --config.architecture=x64
- name: Run Chromatic
uses: chromaui/action@c93e0bc3a63aa176e14a75b61a31847cbfdd341c # latest
uses: chromaui/action@4c20b95e9d3209ecfdf9cd6aace6bbde71ba1694 # v13.3.4
with:
# ⚠️ Make sure to configure a `CHROMATIC_PROJECT_TOKEN` repository secret
projectToken: ${{ secrets.CHROMATIC_PROJECT_TOKEN }}
workingDir: apps/storybook
zip: true

View File

@@ -203,6 +203,14 @@ Here are a few options:
</a>
## Thanks
Formbricks is supported by the following companies who provide us with their tools for free as part of their open-source support:
<a href="https://www.chromatic.com/"><img src="https://user-images.githubusercontent.com/321738/84662277-e3db4f80-af1b-11ea-88f5-91d67a5e59f6.png" width="153" height="30" alt="Chromatic" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
<a href="https://sentry.io/"><img src="https://github.com/user-attachments/assets/d743ffd4-b575-4802-a29a-10136be9227e" width="150" height="30" alt="Sentry" /></a>
<a id="contact-us"></a>
## 📆 Contact us

View File

@@ -227,7 +227,7 @@ export const ProjectSettings = ({
alt="Logo"
width={256}
height={56}
className="absolute left-2 top-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
className="absolute top-2 left-2 -mb-6 h-20 w-auto max-w-64 rounded-lg border object-contain p-1"
/>
)}
<p className="text-sm text-slate-400">{t("common.preview")}</p>

View File

@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
/>
{projects.length >= 1 && (
<Button
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
variant="ghost"
asChild>
<Link href={"/"}>

View File

@@ -185,7 +185,7 @@ export const MainNavigation = ({
size="icon"
onClick={toggleSidebar}
className={cn(
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:outline-none focus:ring-0 focus:ring-transparent"
"rounded-xl bg-slate-50 p-1 text-slate-600 transition-all hover:bg-slate-100 focus:ring-0 focus:ring-transparent focus:outline-none"
)}>
{isCollapsed ? (
<PanelLeftOpenIcon strokeWidth={1.5} />

View File

@@ -18,7 +18,8 @@
"zh-Hant-TW",
"nl-NL",
"es-ES",
"sv-SE"
"sv-SE",
"ru-RU"
]
},
"version": 1.8

View File

@@ -446,14 +446,12 @@ checksums:
emails/forgot_password_email_text: 5100fa2fe2180ded9cb2d89b4f77d2e0
emails/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
emails/imprint: c4e5f2a1994d3cc5896b200709cc499c
emails/invite_accepted_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_accepted_email_heading: 80763c6e4585cd57fa58e4d2d82e6500
emails/invite_accepted_email_subject: 4f5f2a68c98dd1dd01143fcae3be5562
emails/invite_accepted_email_text_par1: b27eadc4779c9fa477103d136a6acab9
emails/invite_accepted_email_text_par2: c77209b510baf0415264fdb5ab8076a8
emails/invite_accepted_email_text: 48d792826ab9a97eed27599c17ec70d5
emails/invite_email_button_label: 02099d40cd11e717c0431fa43e68272c
emails/invite_email_heading: 6ff6dff269b0f1ac1b73912c9e344343
emails/invite_email_text_par1: 70b976a3d4a5509f6d905f9f3f962ada
emails/invite_email_text_par2: 14da6da9fdbc21a1cb38988abac7932d
emails/invite_email_heading: d9f9b18e4de575980de3cde3e4ed08bf
emails/invite_email_text: 1499fa615105121a133440929b039a64
emails/invite_member_email_subject: 295e329b1642339dc7cc2b49a687e1f8
emails/new_email_verification_text: b7f00f47d04afa9e872176d9933f2d93
emails/number_variable: d4f2bbb1965c791cf9921a5112914f3f
@@ -1101,6 +1099,13 @@ checksums:
environments/settings/teams/please_fill_all_project_fields: 6712059df63c432ecd31f3c52b8e4d87
environments/settings/teams/read: 2494ca23d10e5b6381eb271aceeb5270
environments/settings/teams/read_write: 278a90dade128198d4c93ac00c345320
environments/settings/teams/security_updates_description: 17c49b565a7dde28b810f67af2e8db07
environments/settings/teams/security_updates_enroll: edcc8815899ece9209ce981c26c44df3
environments/settings/teams/security_updates_enrolled: 98863ec2d846b7a13ff1ed38ce1038fe
environments/settings/teams/security_updates_enrolled_description: d9c7605767af8f4d7265cba7dfba5f11
environments/settings/teams/security_updates_enrolled_successfully: 3bbb41fac1c04effec3af8ffbd8b72c5
environments/settings/teams/security_updates_enrolling: 15ca7daa32fb57e18a0a6357de26eb4b
environments/settings/teams/security_updates_title: 2f5f5f55bb9a325b5c8228bcad4f2784
environments/settings/teams/select_member: 7f4a38312aabbbe3fe92756b57bd5d75
environments/settings/teams/select_project: 6e4f4a24178660851d9ae0874706be9f
environments/settings/teams/team_admin: 5df68214685738029af678ae1d5912bb
@@ -1910,9 +1915,9 @@ checksums:
s/want_to_respond: fbb26054f6af3b625cb569e19063302f
setup/intro/get_started: 5c783951b0100a168bdd2161ff294833
setup/intro/made_with_love_in_kiel: 1bbdd6e93bcdf7cbfbcac16db448a2e4
setup/intro/paragraph_1: 360c902da0db044c6cc346ac18099902
setup/intro/paragraph_1: 41e6a1e7c9a4a1922c7064a89f6733fd
setup/intro/paragraph_2: 5b3cce4d8c75bab4d671e2af7fc7ee9f
setup/intro/paragraph_3: 0675e53f2f48e3a04db6e52698bdebae
setup/intro/paragraph_3: 5bf4718d4c44ff27e55e0880331f293d
setup/intro/welcome_to_formbricks: 561427153e3effa108f54407dfc2126f
setup/invite/add_another_member: 02947deaa4710893794f3cc6e160c2b4
setup/invite/continue: 3cfba90b4600131e82fc4260c568d044

View File

@@ -177,6 +177,7 @@ export const AVAILABLE_LOCALES: TUserLocale[] = [
"zh-Hans-CN",
"es-ES",
"sv-SE",
"ru-RU",
];
// Billing constants

View File

@@ -141,6 +141,7 @@ export const appLanguages = [
"nl-NL": "Engels (VS)",
"es-ES": "Inglés (EE.UU.)",
"sv-SE": "Engelska (USA)",
"ru-RU": "Английский (США)",
},
},
{
@@ -158,6 +159,7 @@ export const appLanguages = [
"nl-NL": "Duits",
"es-ES": "Alemán",
"sv-SE": "Tyska",
"ru-RU": "Немецкий",
},
},
{
@@ -175,6 +177,7 @@ export const appLanguages = [
"nl-NL": "Portugees (Brazilië)",
"es-ES": "Portugués (Brasil)",
"sv-SE": "Portugisiska (Brasilien)",
"ru-RU": "Португальский (Бразилия)",
},
},
{
@@ -192,6 +195,7 @@ export const appLanguages = [
"nl-NL": "Frans",
"es-ES": "Francés",
"sv-SE": "Franska",
"ru-RU": "Французский",
},
},
{
@@ -209,6 +213,7 @@ export const appLanguages = [
"nl-NL": "Chinees (Traditioneel)",
"es-ES": "Chino (Tradicional)",
"sv-SE": "Kinesiska (traditionell)",
"ru-RU": "Китайский (традиционный)",
},
},
{
@@ -226,6 +231,7 @@ export const appLanguages = [
"nl-NL": "Portugees (Portugal)",
"es-ES": "Portugués (Portugal)",
"sv-SE": "Portugisiska (Portugal)",
"ru-RU": "Португальский (Португалия)",
},
},
{
@@ -243,6 +249,7 @@ export const appLanguages = [
"nl-NL": "Roemeens",
"es-ES": "Rumano",
"sv-SE": "Rumänska",
"ru-RU": "Румынский",
},
},
{
@@ -260,6 +267,7 @@ export const appLanguages = [
"nl-NL": "Japans",
"es-ES": "Japonés",
"sv-SE": "Japanska",
"ru-RU": "Японский",
},
},
{
@@ -277,6 +285,7 @@ export const appLanguages = [
"nl-NL": "Chinees (Vereenvoudigd)",
"es-ES": "Chino (Simplificado)",
"sv-SE": "Kinesiska (förenklad)",
"ru-RU": "Китайский (упрощенный)",
},
},
{
@@ -294,6 +303,7 @@ export const appLanguages = [
"nl-NL": "Nederlands",
"es-ES": "Neerlandés",
"sv-SE": "Nederländska",
"ru-RU": "Голландский",
},
},
{
@@ -311,6 +321,7 @@ export const appLanguages = [
"nl-NL": "Spaans",
"es-ES": "Español",
"sv-SE": "Spanska",
"ru-RU": "Испанский",
},
},
{
@@ -328,6 +339,7 @@ export const appLanguages = [
"nl-NL": "Zweeds",
"es-ES": "Sueco",
"sv-SE": "Svenska",
"ru-RU": "Шведский",
},
},
];

View File

@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, sv, zhCN, zhTW } from "date-fns/locale";
import { de, enUS, es, fr, ja, nl, pt, ptBR, ro, ru, sv, zhCN, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string | null) => {
@@ -107,6 +107,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return zhCN;
case "es-ES":
return es;
case "ru-RU":
return ru;
}
};

View File

@@ -61,6 +61,9 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://www.vimeo.com/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
expect(convertToEmbedUrl("https://player.vimeo.com/video/123456789")).toBe(
"https://player.vimeo.com/video/123456789"
);
});
test("converts Loom URL to embed URL", () => {
@@ -70,6 +73,9 @@ describe("convertToEmbedUrl", () => {
expect(convertToEmbedUrl("https://loom.com/share/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
expect(convertToEmbedUrl("https://www.loom.com/embed/abcdef123456")).toBe(
"https://www.loom.com/embed/abcdef123456"
);
});
test("returns undefined for unsupported URLs", () => {
@@ -109,6 +115,7 @@ describe("extractVimeoId", () => {
test("extracts video ID from Vimeo URLs", () => {
expect(extractVimeoId("https://vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://www.vimeo.com/123456789")).toBe("123456789");
expect(extractVimeoId("https://player.vimeo.com/video/123456789")).toBe("123456789");
});
test("returns null for invalid Vimeo URLs", () => {
@@ -121,6 +128,7 @@ describe("extractLoomId", () => {
test("extracts video ID from Loom URLs", () => {
expect(extractLoomId("https://loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/share/abcdef123456")).toBe("abcdef123456");
expect(extractLoomId("https://www.loom.com/embed/abcdef123456")).toBe("abcdef123456");
});
test("returns null for invalid Loom URLs", async () => {

View File

@@ -26,7 +26,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
if (vimeoUrl.protocol !== "https:") return false;
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
const hostname = vimeoUrl.hostname;
return vimeoDomains.includes(hostname);
@@ -74,7 +74,7 @@ export const extractYoutubeId = (url: string): string | null => {
};
export const extractVimeoId = (url: string): string | null => {
const regExp = /vimeo\.com\/(\d+)/;
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
const match = regExp.exec(url);
if (match?.[1]) {
@@ -85,7 +85,7 @@ export const extractVimeoId = (url: string): string | null => {
};
export const extractLoomId = (url: string): string | null => {
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
const regExp = /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/;
const match = regExp.exec(url);
if (match?.[1]) {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "Du hast einen Link angefordert, um dein Passwort zu ändern. Du kannst dies tun, indem Du auf den untenstehenden Link klickst:",
"hidden_field": "Verstecktes Feld",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_heading": "Hey {inviterName}",
"invite_accepted_email_subject": "Du hast einen neuen Organisation-Mitglied!",
"invite_accepted_email_text_par1": "Wollte dir nur Bescheid geben, dass",
"invite_accepted_email_text_par2": "deine Einladung angenommen hat. Viel Spaß bei der Zusammenarbeit!",
"invite_accepted_email_text": "Nur zur Info: {inviteeName} hat deine Einladung angenommen. Viel Spaß bei der Zusammenarbeit!",
"invite_email_button_label": "Organisation beitreten",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Dein Kollege",
"invite_email_text_par2": "hat Dich eingeladen, Formbricks zu nutzen. Um die Einladung anzunehmen, klicke bitte auf den untenstehenden Link:",
"invite_email_heading": "Hey {inviteeName}",
"invite_email_text": "Dein Kollege {inviterName} hat dich eingeladen, bei Formbricks mitzumachen. Um die Einladung anzunehmen, klicke bitte auf den Link unten:",
"invite_member_email_subject": "Du wurdest eingeladen, Formbricks zu nutzen!",
"new_email_verification_text": "Um Ihre neue E-Mail-Adresse zu bestätigen, klicken Sie bitte auf die Schaltfläche unten:",
"number_variable": "Zahlenvariable",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Bitte fülle alle Felder aus, um ein neues Projekt hinzuzufügen.",
"read": "Lesen",
"read_write": "Lesen & Schreiben",
"security_updates_description": "Melden Sie sich für unsere Sicherheits-Mailingliste an, um informiert zu bleiben, falls Sicherheitslücken gefunden werden.",
"security_updates_enroll": "Jetzt anmelden",
"security_updates_enrolled": "Angemeldet",
"security_updates_enrolled_description": "Sie sind angemeldet, um Sicherheitsupdates unter {email} zu erhalten.",
"security_updates_enrolled_successfully": "Erfolgreich für Sicherheitsupdates angemeldet!",
"security_updates_enrolling": "Wird angemeldet...",
"security_updates_title": "Sicherheitsupdates",
"select_member": "Mitglied auswählen",
"select_project": "Projekt auswählen",
"team_admin": "Team-Admin",
@@ -2048,7 +2053,7 @@
"made_with_love_in_kiel": "Gebaut mit 🤍 in Deutschland",
"paragraph_1": "Formbricks ist eine Experience Management Suite, die auf der <b>am schnellsten wachsenden Open-Source-Umfrageplattform</b> weltweit basiert.",
"paragraph_2": "Führe gezielte Umfragen auf Websites, in Apps oder überall online durch. Sammle wertvolle Insights, um unwiderstehliche Erlebnisse für Kunden, Nutzer und Mitarbeiter zu gestalten.",
"paragraph_3": "Wir schreiben DATENSCHUTZ groß (ha!). Hoste Formbricks selbst, um <b>volle Kontrolle über deine Daten</b> zu behalten.",
"paragraph_3": "Wir verpflichten uns zu höchstem Datenschutz. Hosten Sie selbst, um die <b>volle Kontrolle über Ihre Daten</b> zu behalten.",
"welcome_to_formbricks": "Willkommen bei Formbricks!"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "You have requested a link to change your password. You can do this by clicking the link below:",
"hidden_field": "Hidden field",
"imprint": "Imprint",
"invite_accepted_email_heading": "Hey",
"invite_accepted_email_heading": "Hey {inviterName}",
"invite_accepted_email_subject": "You've got a new organization member!",
"invite_accepted_email_text_par1": "Just letting you know that",
"invite_accepted_email_text_par2": "accepted your invitation. Have fun collaborating!",
"invite_accepted_email_text": "Just letting you know that {inviteeName} accepted your invitation. Have fun collaborating!",
"invite_email_button_label": "Join organization",
"invite_email_heading": "Hey",
"invite_email_text_par1": "Your colleague",
"invite_email_text_par2": "invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_email_heading": "Hey {inviteeName}",
"invite_email_text": "Your colleague {inviterName} invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"new_email_verification_text": "To verify your new email address, please click the button below:",
"number_variable": "Number variable",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Please fill all the fields to add a new project.",
"read": "Read",
"read_write": "Read & Write",
"security_updates_description": "Enroll to our Security Mailing List to stay informed if vulnerabilities are found.",
"security_updates_enroll": "Enroll now",
"security_updates_enrolled": "Enrolled",
"security_updates_enrolled_description": "You're enrolled to receive security updates at {email}.",
"security_updates_enrolled_successfully": "Successfully enrolled for security updates!",
"security_updates_enrolling": "Enrolling...",
"security_updates_title": "Security Updates",
"select_member": "Select member",
"select_project": "Select project",
"team_admin": "Team Admin",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Get started",
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
"paragraph_1": "Formbricks is an Experience Management Suite built of the <b>fastest growing open source survey platform</b> worldwide.",
"paragraph_1": "Formbricks is an Experience Management Suite built on the <b>fastest growing open-source survey platform</b> worldwide.",
"paragraph_2": "Run targeted surveys on websites, in apps or anywhere online. Gather valuable insights to <b>craft irresistible experiences</b> for customers, users and employees.",
"paragraph_3": "We're commited to highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"paragraph_3": "We're committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
"welcome_to_formbricks": "Welcome to Formbricks!"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "Has solicitado un enlace para cambiar tu contraseña. Puedes hacerlo haciendo clic en el enlace a continuación:",
"hidden_field": "Campo oculto",
"imprint": "Aviso legal",
"invite_accepted_email_heading": "Hola",
"invite_accepted_email_heading": "Hola, {inviterName}",
"invite_accepted_email_subject": "¡Tienes un nuevo miembro en la organización!",
"invite_accepted_email_text_par1": "Solo para informarte que",
"invite_accepted_email_text_par2": "ha aceptado tu invitación. ¡Diviértete colaborando!",
"invite_accepted_email_text": "Te informamos que {inviteeName} ha aceptado tu invitación. ¡Que disfrutéis colaborando!",
"invite_email_button_label": "Unirse a la organización",
"invite_email_heading": "Hola",
"invite_email_text_par1": "Tu colega",
"invite_email_text_par2": "te ha invitado a unirte a Formbricks. Para aceptar la invitación, por favor haz clic en el enlace a continuación:",
"invite_email_heading": "Hola, {inviteeName}",
"invite_email_text": "Tu compañero {inviterName} te ha invitado a unirte a Formbricks. Para aceptar la invitación, haz clic en el enlace que aparece a continuación:",
"invite_member_email_subject": "¡Estás invitado a colaborar en Formbricks!",
"new_email_verification_text": "Para verificar tu nueva dirección de correo electrónico, por favor haz clic en el botón a continuación:",
"number_variable": "Variable numérica",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Por favor, rellena todos los campos para añadir un nuevo proyecto.",
"read": "Lectura",
"read_write": "Lectura y escritura",
"security_updates_description": "Inscríbete en nuestra lista de correo de seguridad para mantenerte informado si se encuentran vulnerabilidades.",
"security_updates_enroll": "Inscribirse ahora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Estás inscrito para recibir actualizaciones de seguridad en {email}.",
"security_updates_enrolled_successfully": "Te has inscrito correctamente para recibir actualizaciones de seguridad.",
"security_updates_enrolling": "Inscribiendo...",
"security_updates_title": "Actualizaciones de seguridad",
"select_member": "Seleccionar miembro",
"select_project": "Seleccionar proyecto",
"team_admin": "Administrador de equipo",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Comenzar",
"made_with_love_in_kiel": "Hecho con 🤍 en Alemania",
"paragraph_1": "Formbricks es una Suite de Gestión de Experiencia construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> en todo el mundo.",
"paragraph_1": "Formbricks es una suite de gestión de experiencias construida sobre la <b>plataforma de encuestas de código abierto de más rápido crecimiento</b> a nivel mundial.",
"paragraph_2": "Realiza encuestas dirigidas en sitios web, en aplicaciones o en cualquier lugar online. Recopila información valiosa para <b>crear experiencias irresistibles</b> para clientes, usuarios y empleados.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Alójalo tú mismo para mantener <b>control total sobre tus datos</b>.",
"paragraph_3": "Estamos comprometidos con el más alto grado de privacidad de datos. Aloja en tu propio servidor para mantener el <b>control total sobre tus datos</b>.",
"welcome_to_formbricks": "¡Bienvenido a Formbricks!"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "Vous avez demandé un lien pour changer votre mot de passe. Vous pouvez le faire en cliquant sur le lien ci-dessous :",
"hidden_field": "Champ caché",
"imprint": "Impressum",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_heading": "Salut {inviterName}",
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
"invite_accepted_email_text_par1": "Je te fais savoir que",
"invite_accepted_email_text_par2": "accepté votre invitation. Amusez-vous bien à collaborer !",
"invite_accepted_email_text": "Juste pour te faire savoir que {inviteeName} a accepté ton invitation. Amusez-vous bien à collaborer!",
"invite_email_button_label": "Rejoindre l'organisation",
"invite_email_heading": "Salut",
"invite_email_text_par1": "Votre collègue",
"invite_email_text_par2": "vous a invité à les rejoindre sur Formbricks. Pour accepter l'invitation, veuillez cliquer sur le lien ci-dessous :",
"invite_email_heading": "Salut {inviteeName}",
"invite_email_text": "Ton collègue {inviterName} t'a invité à le rejoindre sur Formbricks. Pour accepter l'invitation, clique sur le lien ci-dessous:",
"invite_member_email_subject": "Vous avez été invité à collaborer sur Formbricks !",
"new_email_verification_text": "Pour confirmer votre nouvelle adresse e-mail, veuillez cliquer sur le bouton ci-dessous :",
"number_variable": "Variable numérique",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Veuillez remplir tous les champs pour ajouter un nouveau projet.",
"read": "Lire",
"read_write": "Lire et Écrire",
"security_updates_description": "Inscrivez-vous à notre liste de diffusion sécurité pour être informé si des vulnérabilités sont découvertes.",
"security_updates_enroll": "S'inscrire maintenant",
"security_updates_enrolled": "Inscrit",
"security_updates_enrolled_description": "Vous êtes inscrit pour recevoir les mises à jour de sécurité à {email}.",
"security_updates_enrolled_successfully": "Inscription aux mises à jour de sécurité réussie!",
"security_updates_enrolling": "Inscription en cours...",
"security_updates_title": "Mises à jour de sécurité",
"select_member": "Sélectionner membre",
"select_project": "Sélectionner projet",
"team_admin": "Administrateur d'équipe",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Commencer",
"made_with_love_in_kiel": "Fabriqué avec 🤍 en Allemagne",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme d'enquête open source à la croissance la plus rapide</b> au monde.",
"paragraph_1": "Formbricks est une suite de gestion de l'expérience construite sur la <b>plateforme de sondage open-source à la croissance la plus rapide</b> au monde.",
"paragraph_2": "Réalisez des enquêtes ciblées sur des sites web, dans des applications ou partout en ligne. Collectez des informations précieuses pour <b>créer des expériences irrésistibles</b> pour les clients, les utilisateurs et les employés.",
"paragraph_3": "Nous sommes engagés à garantir le plus haut niveau de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total sur vos données</b>. Toujours.",
"paragraph_3": "Nous nous engageons à respecter le plus haut degré de confidentialité des données. Auto-hébergez pour garder <b>le contrôle total de vos données</b>.",
"welcome_to_formbricks": "Bienvenue sur Formbricks !"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "パスワード変更のリンクがリクエストされました。以下のリンクをクリックして変更できます。",
"hidden_field": "非表示フィールド",
"imprint": "企業情報",
"invite_accepted_email_heading": "こんにちは",
"invite_accepted_email_heading": "{inviterName}さん",
"invite_accepted_email_subject": "新しい組織メンバーが加わりました!",
"invite_accepted_email_text_par1": "お知らせですが、",
"invite_accepted_email_text_par2": "があなたの招待を承認しました。コラボレーションを楽しんでください!",
"invite_accepted_email_text": "{inviteeName}さんがあなたの招待を承認しました。コラボレーションをお楽しみください!",
"invite_email_button_label": "組織に参加",
"invite_email_heading": "こんにちは",
"invite_email_text_par1": "あなたの同僚の",
"invite_email_text_par2": "が、Formbricksへの参加をあなたに招待しました。招待を承認するには、以下のリンクをクリックしてください。",
"invite_email_heading": "{inviteeName}さん",
"invite_email_text": "同僚の{inviterName}さんがFormbricksへの参加を招待しています。招待を承認するには、以下のリンクをクリックしてください",
"invite_member_email_subject": "Formbricksでのコラボレーションに招待されました",
"new_email_verification_text": "新しいメールアドレスを認証するには、以下のボタンをクリックしてください。",
"number_variable": "数値変数",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "新しいプロジェクトを追加するには、すべてのフィールドを記入してください。",
"read": "読み取り",
"read_write": "読み書き",
"security_updates_description": "脆弱性が発見された際に通知を受け取るため、セキュリティメーリングリストに登録してください。",
"security_updates_enroll": "今すぐ登録",
"security_updates_enrolled": "登録済み",
"security_updates_enrolled_description": "{email}でセキュリティアップデートを受信するよう登録されています。",
"security_updates_enrolled_successfully": "セキュリティアップデートの登録が完了しました",
"security_updates_enrolling": "登録中...",
"security_updates_title": "セキュリティアップデート",
"select_member": "メンバーを選択",
"select_project": "プロジェクトを選択",
"team_admin": "チーム管理者",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "始める",
"made_with_love_in_kiel": "キールで愛を込めて作られました 🤍",
"paragraph_1": "Formbricksは、世界で<b>最も急速に成長しているオープンソースのフォームプラットフォーム</b>から構築されたエクスペリエンス管理スイートです。",
"paragraph_1": "Formbricksは、世界で<b>最も急成長しているオープンソースのアンケートプラットフォーム</b>をベースに構築されたエクスペリエンス管理スイートです。",
"paragraph_2": "ウェブサイト、アプリ、またはオンラインのどこでもターゲットを絞ったフォームを実行できます。貴重な洞察を収集して、顧客、ユーザー、従業員向けの<b>魅力的な体験</b>を作り出します。",
"paragraph_3": "私たちは最高のデータプライバシーを約束します。セルフホストして、<b>データを完全に制御</b>できます。",
"paragraph_3": "私たちは最高レベルのデータプライバシーを重視しています。セルフホスティングにより、<b>データを完全に管理</b>できます。",
"welcome_to_formbricks": "Formbricksへようこそ"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "U heeft een link aangevraagd om uw wachtwoord te wijzigen. Dit kunt u doen door op onderstaande link te klikken:",
"hidden_field": "Verborgen veld",
"imprint": "Afdruk",
"invite_accepted_email_heading": "Hoi",
"invite_accepted_email_heading": "Hé {inviterName}",
"invite_accepted_email_subject": "Je hebt een nieuw organisatielid!",
"invite_accepted_email_text_par1": "Laat het je gewoon weten",
"invite_accepted_email_text_par2": "heeft uw uitnodiging geaccepteerd. Veel plezier met samenwerken!",
"invite_accepted_email_text": "We wilden je even laten weten dat {inviteeName} je uitnodiging heeft geaccepteerd. Veel plezier met samenwerken!",
"invite_email_button_label": "Sluit je aan bij de organisatie",
"invite_email_heading": "Hoi",
"invite_email_text_par1": "Jouw collega",
"invite_email_text_par2": "nodigde je uit om je bij Formbricks aan te sluiten. Om de uitnodiging te accepteren, klikt u op de onderstaande link:",
"invite_email_heading": "Hé {inviteeName}",
"invite_email_text": "Je collega {inviterName} heeft je uitgenodigd om samen te werken bij Formbricks. Klik op onderstaande link om de uitnodiging te accepteren:",
"invite_member_email_subject": "Je bent uitgenodigd om samen te werken aan Formbricks!",
"new_email_verification_text": "Om uw nieuwe e-mailadres te verifiëren, klikt u op de onderstaande knop:",
"number_variable": "Numerieke variabele",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Vul alle velden in om een nieuw project toe te voegen.",
"read": "Lezen",
"read_write": "Lezen en schrijven",
"security_updates_description": "Schrijf je in voor onze beveiligingsmailinglijst om op de hoogte te blijven als er kwetsbaarheden worden gevonden.",
"security_updates_enroll": "Nu inschrijven",
"security_updates_enrolled": "Ingeschreven",
"security_updates_enrolled_description": "Je bent ingeschreven om beveiligingsupdates te ontvangen op {email}.",
"security_updates_enrolled_successfully": "Succesvol ingeschreven voor beveiligingsupdates!",
"security_updates_enrolling": "Bezig met inschrijven...",
"security_updates_title": "Beveiligingsupdates",
"select_member": "Selecteer lid",
"select_project": "Selecteer project",
"team_admin": "Teambeheerder",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Ga aan de slag",
"made_with_love_in_kiel": "Gemaakt met 🤍 in Duitsland",
"paragraph_1": "Formbricks is een Experience Management Suite die is gebouwd op het <b>snelst groeiende open source enquêteplatform</b> wereldwijd.",
"paragraph_1": "Formbricks is een Experience Management Suite gebouwd op het <b>snelst groeiende open-source enquêteplatform</b> wereldwijd.",
"paragraph_2": "Voer gerichte enquêtes uit op websites, in apps of waar dan ook online. Verzamel waardevolle inzichten om <b>onweerstaanbare ervaringen te creëren</b> voor klanten, gebruikers en medewerkers.",
"paragraph_3": "We streven naar de hoogste mate van gegevensprivacy. Zelfhosting om <b>volledige controle over uw gegevens</b> te behouden.",
"paragraph_3": "We zijn toegewijd aan de hoogste mate van gegevensprivacy. Self-host om <b>volledige controle over je gegevens</b> te behouden.",
"welcome_to_formbricks": "Welkom bij Formbricks!"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "Você pediu um link pra trocar sua senha. Você pode fazer isso clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressum",
"invite_accepted_email_heading": "E aí",
"invite_accepted_email_heading": "Olá, {inviterName}",
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
"invite_accepted_email_text_par1": "Só pra te avisar que",
"invite_accepted_email_text_par2": "aceitou seu convite. Divirta-se colaborando!",
"invite_accepted_email_text": "Só para você saber que {inviteeName} aceitou seu convite. Divirta-se colaborando!",
"invite_email_button_label": "Entrar na organização",
"invite_email_heading": "E aí",
"invite_email_text_par1": "Seu colega",
"invite_email_text_par2": "te convidou para se juntar a eles na Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_email_heading": "Olá, {inviteeName}",
"invite_email_text": "Seu colega {inviterName} convidou você para se juntar a ele no Formbricks. Para aceitar o convite, clique no link abaixo:",
"invite_member_email_subject": "Você foi convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar seu novo endereço de e-mail, clique no botão abaixo:",
"number_variable": "Variável numérica",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Leitura",
"read_write": "Leitura & Escrita",
"security_updates_description": "Inscreva-se na nossa lista de e-mails de segurança para ser informado caso vulnerabilidades sejam encontradas.",
"security_updates_enroll": "Inscrever-se agora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Você está inscrito para receber atualizações de segurança em {email}.",
"security_updates_enrolled_successfully": "Inscrito com sucesso para atualizações de segurança!",
"security_updates_enrolling": "Inscrevendo...",
"security_updates_title": "Atualizações de segurança",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da equipe",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 em Alemanha",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída na <b>plataforma de pesquisa open source que mais cresce</b> no mundo.",
"paragraph_1": "Formbricks é uma suíte de gerenciamento de experiência construída sobre a <b>plataforma de pesquisa de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_2": "Faça pesquisas direcionadas em sites, apps ou em qualquer lugar online. Recolha insights valiosos para criar experiências irresistíveis para clientes, usuários e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto nível de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>. Sempre",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Hospede você mesmo para manter <b>controle total sobre seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "Solicitou um link para alterar a sua palavra-passe. Pode fazê-lo clicando no link abaixo:",
"hidden_field": "Campo oculto",
"imprint": "Impressão",
"invite_accepted_email_heading": "Olá",
"invite_accepted_email_heading": "Olá {inviterName}",
"invite_accepted_email_subject": "Tem um novo membro na organização!",
"invite_accepted_email_text_par1": "Só para te informar que",
"invite_accepted_email_text_par2": "aceitou o seu convite. Divirta-se a colaborar!",
"invite_accepted_email_text": "Só para informar que {inviteeName} aceitou o teu convite. Divirtam-se a colaborar!",
"invite_email_button_label": "Junte-se à organização",
"invite_email_heading": "Olá",
"invite_email_text_par1": "O seu colega",
"invite_email_text_par2": "convidou-o a juntar-se a eles no Formbricks. Para aceitar o convite, por favor clique no link abaixo:",
"invite_email_heading": "Olá {inviteeName}",
"invite_email_text": "O teu colega {inviterName} convidou-te para te juntares a ele no Formbricks. Para aceitar o convite, clica na ligação abaixo:",
"invite_member_email_subject": "Está convidado a colaborar no Formbricks!",
"new_email_verification_text": "Para verificar o seu novo endereço de email, por favor clique no botão abaixo:",
"number_variable": "Variável numérica",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Por favor, preencha todos os campos para adicionar um novo projeto.",
"read": "Ler",
"read_write": "Ler e Escrever",
"security_updates_description": "Inscreva-se na nossa lista de correio de segurança para se manter informado caso sejam encontradas vulnerabilidades.",
"security_updates_enroll": "Inscrever agora",
"security_updates_enrolled": "Inscrito",
"security_updates_enrolled_description": "Está inscrito para receber atualizações de segurança em {email}.",
"security_updates_enrolled_successfully": "Inscrito com sucesso para atualizações de segurança!",
"security_updates_enrolling": "A inscrever...",
"security_updates_title": "Atualizações de segurança",
"select_member": "Selecionar membro",
"select_project": "Selecionar projeto",
"team_admin": "Administrador da Equipa",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Começar",
"made_with_love_in_kiel": "Feito com 🤍 na Alemanha",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos de código aberto de crescimento mais rápido</b> do mundo.",
"paragraph_1": "Formbricks é uma Suite de Gestão de Experiência construída na <b>plataforma de inquéritos open-source de crescimento mais rápido</b> a nível mundial.",
"paragraph_2": "Execute inquéritos direcionados em websites, em apps ou em qualquer lugar online. Recolha informações valiosas para <b>criar experiências irresistíveis</b> para clientes, utilizadores e funcionários.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Auto-hospede para manter <b>controlo total sobre os seus dados</b>.",
"paragraph_3": "Estamos comprometidos com o mais alto grau de privacidade de dados. Faça self-host para manter <b>controlo total sobre os seus dados</b>.",
"welcome_to_formbricks": "Bem-vindo ao Formbricks!"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "Ați solicitat un link pentru a vă schimba parola. Puteți face acest lucru făcând clic pe linkul de mai jos:",
"hidden_field": "Câmp ascuns",
"imprint": "Amprentă",
"invite_accepted_email_heading": "Salut",
"invite_accepted_email_heading": "Salut, {inviterName}",
"invite_accepted_email_subject": "Ai un nou membru în organizație!",
"invite_accepted_email_text_par1": "Doar te anunț că",
"invite_accepted_email_text_par2": "a acceptat invitația ta. Distracție plăcută colaborând!",
"invite_accepted_email_text": "Vrem doar să te anunțăm {inviteeName} a acceptat invitația ta. Spor la colaborare!",
"invite_email_button_label": "Alătură-te organizației",
"invite_email_heading": "Hei",
"invite_email_text_par1": "Colegul tău",
"invite_email_text_par2": "te-a invitat să li te alături la Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_email_heading": "Salut, {inviteeName}",
"invite_email_text": "Colegul tău, {inviterName}, te-a invitat să i te alături pe Formbricks. Pentru a accepta invitația, te rugăm să dai click pe linkul de mai jos:",
"invite_member_email_subject": "Ești invitat să colaborezi pe Formbricks!",
"new_email_verification_text": "Pentru a verifica noua dumneavoastră adresă de email, vă rugăm să faceți clic pe butonul de mai jos:",
"number_variable": "Variabilă numerică",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Vă rugăm să completați toate câmpurile pentru a adăuga un proiect nou.",
"read": "Citește",
"read_write": "Citire & Scriere",
"security_updates_description": "Înscrie-te la lista noastră de e-mailuri de securitate pentru a fi informat dacă sunt descoperite vulnerabilități.",
"security_updates_enroll": "Înscrie-te acum",
"security_updates_enrolled": "Înscris",
"security_updates_enrolled_description": "Ești înscris pentru a primi actualizări de securitate la {email}.",
"security_updates_enrolled_successfully": "Înscriere reușită pentru actualizările de securitate!",
"security_updates_enrolling": "Se înscrie...",
"security_updates_title": "Actualizări de securitate",
"select_member": "Selectează membrul",
"select_project": "Selectează proiectul",
"team_admin": "Administrator Echipe",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Începeți",
"made_with_love_in_kiel": "Creat cu 🤍 în Germania",
"paragraph_1": "Formbricks este o suită de management al experiențelor construită pe baza <b>platformei de sondaje open source care crește cel mai rapid</b> din lume.",
"paragraph_1": "Formbricks este o suită de management al experienței construită pe <b>cea mai rapidă platformă open-source de sondaje</b> din lume.",
"paragraph_2": "Rulați sondaje direcționate pe site-uri web, în aplicații sau oriunde online. Adunați informații valoroase pentru a <b>crea experiențe irezistibile</b> pentru clienți, utilizatori și angajați.",
"paragraph_3": "Suntem angajați la cel mai înalt grad de confidențialitate a datelor. Găzduirea proprie vă oferă <b>control deplin asupra datelor dumneavoastră</b>.",
"paragraph_3": "Suntem dedicați celui mai înalt nivel de confidențialitate a datelor. Găzduiește local pentru a păstra <b>controlul deplin asupra datelor tale</b>.",
"welcome_to_formbricks": "Bine ai venit la Formbricks!"
},
"invite": {

2960
apps/web/locales/ru-RU.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "Du har begärt en länk för att ändra ditt lösenord. Du kan göra detta genom att klicka på länken nedan:",
"hidden_field": "Dolt fält",
"imprint": "Impressum",
"invite_accepted_email_heading": "Hej",
"invite_accepted_email_heading": "Hej {inviterName}",
"invite_accepted_email_subject": "Du har fått en ny organisationsmedlem!",
"invite_accepted_email_text_par1": "Vi vill bara meddela dig att",
"invite_accepted_email_text_par2": "accepterade din inbjudan. Ha kul med samarbetet!",
"invite_accepted_email_text": "Vi vill bara meddela att {inviteeName} har accepterat din inbjudan. Ha det så kul med samarbetet!",
"invite_email_button_label": "Gå med i organisation",
"invite_email_heading": "Hej",
"invite_email_text_par1": "Din kollega",
"invite_email_text_par2": "bjöd in dig att gå med dem på Formbricks. För att acceptera inbjudan, vänligen klicka på länken nedan:",
"invite_email_heading": "Hej {inviteeName}",
"invite_email_text": "Din kollega {inviterName} har bjudit in dig att gå med dem på Formbricks. För att acceptera inbjudan, klicka på länken nedan:",
"invite_member_email_subject": "Du är inbjuden att samarbeta på Formbricks!",
"new_email_verification_text": "För att verifiera din nya e-postadress, vänligen klicka på knappen nedan:",
"number_variable": "Nummervariabel",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "Vänligen fyll i alla fält för att lägga till ett nytt projekt.",
"read": "Läs",
"read_write": "Läs och skriv",
"security_updates_description": "Anmäl dig till vår säkerhetsmejllista för att hålla dig informerad om sårbarheter upptäcks.",
"security_updates_enroll": "Anmäl dig nu",
"security_updates_enrolled": "Anmäld",
"security_updates_enrolled_description": "Du är anmäld för att ta emot säkerhetsuppdateringar på {email}.",
"security_updates_enrolled_successfully": "Du har anmälts för säkerhetsuppdateringar!",
"security_updates_enrolling": "Anmäler...",
"security_updates_title": "Säkerhetsuppdateringar",
"select_member": "Välj medlem",
"select_project": "Välj projekt",
"team_admin": "Teamadministratör",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "Kom igång",
"made_with_love_in_kiel": "Gjort med 🤍 i Tyskland",
"paragraph_1": "Formbricks är en Experience Management Suite byggd av den <b>snabbast växande öppenkällkods enkätplattformen</b> i världen.",
"paragraph_1": "Formbricks är en Experience Management Suite byggd den <b>snabbast växande open source-enkätplattformen</b> i världen.",
"paragraph_2": "Kör riktade enkäter på webbplatser, i appar eller var som helst online. Samla värdefulla insikter för att <b>skapa oemotståndliga upplevelser</b> för kunder, användare och anställda.",
"paragraph_3": "Vi är engagerade i högsta grad av dataintegritet. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"paragraph_3": "Vi är engagerade i högsta möjliga datasekretess. Självhosta för att behålla <b>full kontroll över dina data</b>.",
"welcome_to_formbricks": "Välkommen till Formbricks!"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "您 已 请求 一个 链接 来 更改 您的 密码。 您 可以 点击 下方 链接 完成 这个 操作:",
"hidden_field": "隐藏字段",
"imprint": "印记",
"invite_accepted_email_heading": "",
"invite_accepted_email_heading": "你好,{inviterName}",
"invite_accepted_email_subject": "你 有 一个 新 成员 进入 组织 了!",
"invite_accepted_email_text_par1": "只是 告诉 你",
"invite_accepted_email_text_par2": "接受了 你的 邀请。 合作 愉快!",
"invite_accepted_email_text": "{inviteeName} 已接受了你的邀请。祝你们合作愉快!",
"invite_email_button_label": "加入 组织",
"invite_email_heading": "",
"invite_email_text_par1": "您的 同事",
"invite_email_text_par2": "邀请您加入他们在 Formbricks 。要接受邀请,请点击下面的链接:",
"invite_email_heading": "你好,{inviteeName}",
"invite_email_text": "你的同事 {inviterName} 邀请你加入 Formbricks。要接受邀请请点击下方链接",
"invite_member_email_subject": "您 被 邀请 来 协作 于 Formbricks",
"new_email_verification_text": "要 验证 您 的 新 邮箱 地址 ,请 点击 下方 的 按钮 ",
"number_variable": "数字变量",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "请 填写 所有 字段 以 添加 新 项目。",
"read": "阅读",
"read_write": "读 & 写",
"security_updates_description": "加入我们的安全邮件列表,及时了解发现的安全漏洞信息。",
"security_updates_enroll": "立即加入",
"security_updates_enrolled": "已加入",
"security_updates_enrolled_description": "您已加入安全更新通知,相关信息将发送至 {email}。",
"security_updates_enrolled_successfully": "已成功加入安全更新通知!",
"security_updates_enrolling": "正在加入...",
"security_updates_title": "安全更新",
"select_member": "选择成员",
"select_project": "选择项目",
"team_admin": "团队管理员",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "开始使用",
"made_with_love_in_kiel": "以 🤍 在 德国 制作",
"paragraph_1": "Formbricks 是一体验管理套件, 基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_1": "Formbricks 是一体验管理套件,基于全球<b>增长最快的开源调平台</b>构建。",
"paragraph_2": "在网站、应用程序或任何在线平台上运行 定向 调查。收集 有价值 的见解,为客户、用户和员工<b>打造 无法抗拒 的体验</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私。 自行托管以保持<b>对您的数据的完全控制</b>。",
"paragraph_3": "我们致力于最高级别的数据隐私保护。自建部署,<b>全面掌控您的数据</b>。",
"welcome_to_formbricks": "欢迎来到 Formbricks !"
},
"invite": {

View File

@@ -475,14 +475,12 @@
"forgot_password_email_text": "您已請求變更密碼的連結。您可以點擊以下連結來執行此操作:",
"hidden_field": "隱藏欄位",
"imprint": "版本訊息",
"invite_accepted_email_heading": "嗨",
"invite_accepted_email_heading": "嗨{inviterName}",
"invite_accepted_email_subject": "您有一位新的組織成員!",
"invite_accepted_email_text_par1": "通知您,",
"invite_accepted_email_text_par2": "接受了您的邀請。合作愉快!",
"invite_accepted_email_text": "通知你,{inviteeName} 已經接受了你的邀請。祝你們合作愉快!",
"invite_email_button_label": "加入組織",
"invite_email_heading": "嗨",
"invite_email_text_par1": "的同事",
"invite_email_text_par2": "邀請您加入 Formbricks。若要接受邀請請點擊以下連結",
"invite_email_heading": "嗨{inviteeName}",
"invite_email_text": "的同事 {inviterName} 邀請你加入他們在 Formbricks。請點擊下方連結以接受邀請",
"invite_member_email_subject": "您被邀請協作 Formbricks",
"new_email_verification_text": "要驗證您的新電子郵件地址,請點擊下面的按鈕:",
"number_variable": "數字變數",
@@ -1182,6 +1180,13 @@
"please_fill_all_project_fields": "請填寫所有欄位以新增新專案。",
"read": "讀取",
"read_write": "讀取和寫入",
"security_updates_description": "加入我們的安全郵件名單,隨時掌握漏洞相關資訊。",
"security_updates_enroll": "立即加入",
"security_updates_enrolled": "已加入",
"security_updates_enrolled_description": "您已加入安全更新通知,將會寄送至 {email}。",
"security_updates_enrolled_successfully": "已成功加入安全更新通知!",
"security_updates_enrolling": "正在加入...",
"security_updates_title": "安全更新",
"select_member": "選擇成員",
"select_project": "選擇專案",
"team_admin": "團隊管理員",
@@ -2046,9 +2051,9 @@
"intro": {
"get_started": "開始使用",
"made_with_love_in_kiel": "用 🤍 在德國製造",
"paragraph_1": "Formbricks 是一套體驗管理套件,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_1": "Formbricks 是一套體驗管理工具,建於全球<b>成長最快的開源問卷平台</b>之上。",
"paragraph_2": "在網站、應用程式或線上任何地方執行目標問卷。收集寶貴的洞察,為客戶、使用者和員工<b>打造無法抗拒的體驗</b>。",
"paragraph_3": "我們致力於最高程度的資料隱私。自託管<b>完全掌控您的資料</b>。",
"paragraph_3": "我們致力於最高等級的資料隱私。自託管,讓您<b>完全掌控您的資料</b>。",
"welcome_to_formbricks": "歡迎使用 Formbricks"
},
"invite": {

View File

@@ -9,7 +9,6 @@ import {
ZContactAttributeKeyInput,
ZGetContactAttributeKeysFilter,
} from "@/modules/api/v2/management/contact-attribute-keys/types/contact-attribute-keys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getContactAttributeKeysEndpoint: ZodOpenApiOperationObject = {
@@ -59,13 +58,11 @@ export const createContactAttributeKeyEndpoint: ZodOpenApiOperationObject = {
};
export const contactAttributeKeyPaths: ZodOpenApiPathsObject = {
"/contact-attribute-keys": {
servers: managementServer,
"/management/contact-attribute-keys": {
get: getContactAttributeKeysEndpoint,
post: createContactAttributeKeyEndpoint,
},
"/contact-attribute-keys/{id}": {
servers: managementServer,
"/management/contact-attribute-keys/{id}": {
get: getContactAttributeKeyEndpoint,
put: updateContactAttributeKeyEndpoint,
delete: deleteContactAttributeKeyEndpoint,

View File

@@ -1,6 +0,0 @@
export const managementServer = [
{
url: `https://app.formbricks.com/api/v2/management`,
description: "Formbricks Management API",
},
];

View File

@@ -1,6 +1,5 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZResponse } from "@formbricks/database/zod/responses";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteResponseEndpoint,
getResponseEndpoint,
@@ -57,13 +56,11 @@ export const createResponseEndpoint: ZodOpenApiOperationObject = {
};
export const responsePaths: ZodOpenApiPathsObject = {
"/responses": {
servers: managementServer,
"/management/responses": {
get: getResponsesEndpoint,
post: createResponseEndpoint,
},
"/responses/{id}": {
servers: managementServer,
"/management/responses/{id}": {
get: getResponseEndpoint,
put: updateResponseEndpoint,
delete: deleteResponseEndpoint,

View File

@@ -1,10 +1,8 @@
import { ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getContactLinksBySegmentEndpoint } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/segments/[segmentId]/lib/openapi";
export const surveyContactLinksBySegmentPaths: ZodOpenApiPathsObject = {
"/surveys/{surveyId}/contact-links/segments/{segmentId}": {
servers: managementServer,
"/management/surveys/{surveyId}/contact-links/segments/{segmentId}": {
get: getContactLinksBySegmentEndpoint,
},
};

View File

@@ -1,7 +1,6 @@
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZSurveyWithoutQuestionType } from "@formbricks/database/zod/surveys";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { getPersonalizedSurveyLink } from "@/modules/api/v2/management/surveys/[surveyId]/contact-links/contacts/[contactId]/lib/openapi";
import { ZGetSurveysFilter, ZSurveyInput } from "@/modules/api/v2/management/surveys/types/surveys";
@@ -52,19 +51,16 @@ export const createSurveyEndpoint: ZodOpenApiOperationObject = {
};
export const surveyPaths: ZodOpenApiPathsObject = {
// "/surveys": {
// servers: managementServer,
// "/management/surveys": {
// get: getSurveysEndpoint,
// post: createSurveyEndpoint,
// },
// "/surveys/{id}": {
// servers: managementServer,
// "/management/surveys/{id}": {
// get: getSurveyEndpoint,
// put: updateSurveyEndpoint,
// delete: deleteSurveyEndpoint,
// },
"/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
servers: managementServer,
"/management/surveys/{surveyId}/contact-links/contacts/{contactId}/": {
get: getPersonalizedSurveyLink,
},
};

View File

@@ -1,6 +1,5 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { ZWebhook } from "@formbricks/database/zod/webhooks";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import {
deleteWebhookEndpoint,
getWebhookEndpoint,
@@ -56,13 +55,11 @@ export const createWebhookEndpoint: ZodOpenApiOperationObject = {
};
export const webhookPaths: ZodOpenApiPathsObject = {
"/webhooks": {
servers: managementServer,
"/management/webhooks": {
get: getWebhooksEndpoint,
post: createWebhookEndpoint,
},
"/webhooks/{id}": {
servers: managementServer,
"/management/webhooks/{id}": {
get: getWebhookEndpoint,
put: updateWebhookEndpoint,
delete: deleteWebhookEndpoint,

View File

@@ -7,7 +7,6 @@ import {
ZProjectTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/project-teams/types/project-teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getProjectTeamsEndpoint: ZodOpenApiOperationObject = {
@@ -119,8 +118,7 @@ export const updateProjectTeamEndpoint: ZodOpenApiOperationObject = {
};
export const projectTeamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/project-teams": {
servers: organizationServer,
"/organizations/{organizationId}/project-teams": {
get: getProjectTeamsEndpoint,
post: createProjectTeamEndpoint,
put: updateProjectTeamEndpoint,

View File

@@ -11,7 +11,6 @@ import {
ZTeamInput,
} from "@/modules/api/v2/organizations/[organizationId]/teams/types/teams";
import { ZOrganizationIdSchema } from "@/modules/api/v2/organizations/[organizationId]/types/organizations";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getTeamsEndpoint: ZodOpenApiOperationObject = {
@@ -69,13 +68,11 @@ export const createTeamEndpoint: ZodOpenApiOperationObject = {
};
export const teamPaths: ZodOpenApiPathsObject = {
"/{organizationId}/teams": {
servers: organizationServer,
"/organizations/{organizationId}/teams": {
get: getTeamsEndpoint,
post: createTeamEndpoint,
},
"/{organizationId}/teams/{id}": {
servers: organizationServer,
"/organizations/{organizationId}/teams/{id}": {
get: getTeamEndpoint,
put: updateTeamEndpoint,
delete: deleteTeamEndpoint,

View File

@@ -7,7 +7,6 @@ import {
ZUserInput,
ZUserInputPatch,
} from "@/modules/api/v2/organizations/[organizationId]/users/types/users";
import { organizationServer } from "@/modules/api/v2/organizations/lib/openapi";
import { makePartialSchema, responseWithMetaSchema } from "@/modules/api/v2/types/openapi-response";
export const getUsersEndpoint: ZodOpenApiOperationObject = {
@@ -96,8 +95,7 @@ export const updateUserEndpoint: ZodOpenApiOperationObject = {
};
export const userPaths: ZodOpenApiPathsObject = {
"/{organizationId}/users": {
servers: organizationServer,
"/organizations/{organizationId}/users": {
get: getUsersEndpoint,
post: createUserEndpoint,
patch: updateUserEndpoint,

View File

@@ -1,6 +0,0 @@
export const organizationServer = [
{
url: `https://app.formbricks.com/api/v2/organizations`,
description: "Formbricks Organizations API",
},
];

View File

@@ -1,6 +1,5 @@
import { z } from "zod";
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { ZContactBulkUploadRequest } from "@/modules/ee/contacts/types/contact";
const bulkContactEndpoint: ZodOpenApiOperationObject = {
@@ -111,8 +110,7 @@ const bulkContactEndpoint: ZodOpenApiOperationObject = {
};
export const bulkContactPaths: ZodOpenApiPathsObject = {
"/contacts/bulk": {
servers: managementServer,
"/management/contacts/bulk": {
put: bulkContactEndpoint,
},
};

View File

@@ -1,5 +1,4 @@
import { ZodOpenApiOperationObject, ZodOpenApiPathsObject } from "zod-openapi";
import { managementServer } from "@/modules/api/v2/management/lib/openapi";
import { makePartialSchema } from "@/modules/api/v2/types/openapi-response";
import { ZContactCreateRequest, ZContactResponse } from "@/modules/ee/contacts/types/contact";
@@ -54,8 +53,7 @@ export const createContactEndpoint: ZodOpenApiOperationObject = {
};
export const contactPaths: ZodOpenApiPathsObject = {
"/contacts": {
servers: managementServer,
"/management/contacts": {
post: createContactEndpoint,
},
};

View File

@@ -1,6 +1,10 @@
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
import {
Column,
Container,
ElementHeader,
Button as EmailButton,
Img,
Link,
@@ -8,11 +12,8 @@ import {
Section,
Tailwind,
Text,
} from "@react-email/components";
import { render } from "@react-email/render";
import { TFunction } from "i18next";
import { CalendarDaysIcon, ExternalLinkIcon, UploadIcon } from "lucide-react";
import React from "react";
render,
} from "@formbricks/email";
import { TSurveyCTAElement, TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { type TSurvey, type TSurveyStyling } from "@formbricks/types/surveys/types";
import { cn } from "@/lib/cn";
@@ -24,7 +25,6 @@ import { isLight, mixColor } from "@/lib/utils/colors";
import { parseRecallInfo } from "@/lib/utils/recall";
import { RatingSmiley } from "@/modules/analysis/components/RatingSmiley";
import { getNPSOptionColor, getRatingNumberOptionColor } from "../lib/utils";
import { ElementHeader } from "./email-element-header";
interface PreviewEmailTemplateProps {
survey: TSurvey;
@@ -183,7 +183,7 @@ export async function PreviewEmailTemplate({
{ctaElement.buttonExternal && ctaElement.ctaButtonLabel && ctaElement.buttonUrl && (
<Container className="mx-0 mt-4 flex max-w-none items-center justify-end">
<EmailButton
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base font-medium leading-4 no-underline shadow-none"
className="text-question-color flex items-center rounded-md border-0 bg-transparent px-3 py-3 text-base leading-4 font-medium no-underline shadow-none"
href={ctaElement.buttonUrl}>
<Text className="inline">
{getLocalizedValue(ctaElement.ctaButtonLabel, defaultLanguageCode)}{" "}
@@ -306,13 +306,13 @@ export async function PreviewEmailTemplate({
{firstQuestion.choices.map((choice) =>
firstQuestion.allowMulti ? (
<Img
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
key={choice.id}
src={choice.imageUrl}
/>
) : (
<Link
className="rounded-custom mb-3 mr-3 inline-block h-[150px] w-[250px]"
className="rounded-custom mr-3 mb-3 inline-block h-[150px] w-[250px]"
href={`${urlWithPrefilling}${firstQuestion.id}=${choice.id}`}
key={choice.id}
target="_blank">
@@ -360,11 +360,11 @@ export async function PreviewEmailTemplate({
<Container className="mx-0">
<Section className="w-full table-auto">
<Row>
<Column className="w-40 break-words px-4 py-2" />
<Column className="w-40 px-4 py-2 break-words" />
{firstQuestion.columns.map((column) => {
return (
<Column
className="text-question-color max-w-40 break-words px-4 py-2 text-center"
className="text-question-color max-w-40 px-4 py-2 text-center break-words"
key={column.id}>
{getLocalizedValue(column.label, "default")}
</Column>
@@ -376,7 +376,7 @@ export async function PreviewEmailTemplate({
<Row
className={`${rowIndex % 2 === 0 ? "bg-input-color" : ""} rounded-custom`}
key={row.id}>
<Column className="w-40 break-words px-4 py-2">
<Column className="w-40 px-4 py-2 break-words">
{getLocalizedValue(row.label, "default")}
</Column>
{firstQuestion.columns.map((column) => {

View File

@@ -1,30 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface ForgotPasswordEmailProps {
verifyLink: string;
}
export async function ForgotPasswordEmail({
verifyLink,
}: ForgotPasswordEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default ForgotPasswordEmail;

View File

@@ -1,34 +0,0 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface VerificationEmailProps {
readonly verifyLink: string;
}
export async function NewEmailVerification({
verifyLink,
}: VerificationEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.new_email_verification_text")}</Text>
<Text className="text-sm">{t("emails.verification_security_notice")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default NewEmailVerification;

View File

@@ -1,20 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
export async function PasswordResetNotifyEmail(): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>{t("emails.password_changed_email_heading")}</Heading>
<Text className="text-sm">{t("emails.password_changed_email_text")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default PasswordResetNotifyEmail;

View File

@@ -1,26 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmailCustomizationPreviewEmailProps {
userName: string;
logoUrl?: string;
}
export async function EmailCustomizationPreviewEmail({
userName,
logoUrl,
}: EmailCustomizationPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.email_customization_preview_email_heading", { userName })}</Heading>
<Text className="text-sm">{t("emails.email_customization_preview_email_text")}</Text>
</Container>
</EmailTemplate>
);
}
export default EmailCustomizationPreviewEmail;

View File

@@ -1,33 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteAcceptedEmailProps {
inviterName: string;
inviteeName: string;
}
export async function InviteAcceptedEmail({
inviterName,
inviteeName,
}: InviteAcceptedEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_accepted_email_heading", { inviterName })} {inviterName}
</Heading>
<Text className="text-sm">
{t("emails.invite_accepted_email_text_par1", { inviteeName })} {inviteeName}{" "}
{t("emails.invite_accepted_email_text_par2")}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default InviteAcceptedEmail;

View File

@@ -1,37 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface InviteEmailProps {
inviteeName: string;
inviterName: string;
verifyLink: string;
}
export async function InviteEmail({
inviteeName,
inviterName,
verifyLink,
}: InviteEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate t={t}>
<Container>
<Heading>
{t("emails.invite_email_heading", { inviteeName })} {inviteeName}
</Heading>
<Text className="text-sm">
{t("emails.invite_email_text_par1", { inviterName })} {inviterName}{" "}
{t("emails.invite_email_text_par2")}
</Text>
<EmailButton href={verifyLink} label={t("emails.invite_email_button_label")} />
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default InviteEmail;

View File

@@ -1,36 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "../../components/email-template";
interface EmbedSurveyPreviewEmailProps {
html: string;
environmentId: string;
logoUrl?: string;
}
export async function EmbedSurveyPreviewEmail({
html,
environmentId,
logoUrl,
}: EmbedSurveyPreviewEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
<Text className="text-sm">
<b>{t("emails.embed_survey_preview_email_didnt_request")}</b>{" "}
{t("emails.embed_survey_preview_email_fight_spam")}
</Text>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: html }} />
<Text className="text-center text-sm text-slate-700">
{t("emails.embed_survey_preview_email_environment_id")}: {environmentId}
</Text>
</Container>
</EmailTemplate>
);
}
export default EmbedSurveyPreviewEmail;

View File

@@ -1,36 +0,0 @@
import { Container, Heading, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
interface LinkSurveyEmailProps {
surveyName: string;
surveyLink: string;
logoUrl: string;
}
export async function LinkSurveyEmail({
surveyName,
surveyLink,
logoUrl,
}: LinkSurveyEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
return (
<EmailTemplate logoUrl={logoUrl} t={t}>
<Container>
<Heading>{t("emails.verification_email_hey")}</Heading>
<Text className="text-sm">{t("emails.verification_email_thanks")}</Text>
<Text className="text-sm">{t("emails.verification_email_to_fill_survey")}</Text>
<EmailButton href={surveyLink} label={t("emails.verification_email_take_survey")} />
<Text className="text-sm text-slate-400">
{t("emails.verification_email_survey_name")}: {surveyName}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default LinkSurveyEmail;

View File

@@ -1,6 +1,18 @@
import { render } from "@react-email/render";
import { createTransport } from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import {
renderEmailCustomizationPreviewEmail,
renderEmbedSurveyPreviewEmail,
renderForgotPasswordEmail,
renderInviteAcceptedEmail,
renderInviteEmail,
renderLinkSurveyEmail,
renderNewEmailVerification,
renderPasswordResetNotifyEmail,
renderResponseFinishedEmail,
renderVerificationEmail,
} from "@formbricks/email";
import { TEmailTemplateLegalProps } from "@formbricks/email/src/types/email";
import { logger } from "@formbricks/logger";
import type { TLinkSurveyEmailData } from "@formbricks/types/email";
import { InvalidInputError } from "@formbricks/types/errors";
@@ -9,8 +21,11 @@ import type { TSurvey } from "@formbricks/types/surveys/types";
import { TUserEmail, TUserLocale } from "@formbricks/types/user";
import {
DEBUG,
IMPRINT_ADDRESS,
IMPRINT_URL,
MAIL_FROM,
MAIL_FROM_NAME,
PRIVACY_URL,
SMTP_AUTHENTICATED,
SMTP_HOST,
SMTP_PASSWORD,
@@ -18,25 +33,24 @@ import {
SMTP_REJECT_UNAUTHORIZED_TLS,
SMTP_SECURE_ENABLED,
SMTP_USER,
TERMS_URL,
WEBAPP_URL,
} from "@/lib/constants";
import { getPublicDomain } from "@/lib/getPublicUrl";
import { createEmailChangeToken, createInviteToken, createToken, createTokenForLinkSurvey } from "@/lib/jwt";
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import NewEmailVerification from "@/modules/email/emails/auth/new-email-verification";
import { EmailCustomizationPreviewEmail } from "@/modules/email/emails/general/email-customization-preview-email";
import { ForgotPasswordEmail } from "./emails/auth/forgot-password-email";
import { PasswordResetNotifyEmail } from "./emails/auth/password-reset-notify-email";
import { VerificationEmail } from "./emails/auth/verification-email";
import { InviteAcceptedEmail } from "./emails/invite/invite-accepted-email";
import { InviteEmail } from "./emails/invite/invite-email";
import { EmbedSurveyPreviewEmail } from "./emails/survey/embed-survey-preview-email";
import { LinkSurveyEmail } from "./emails/survey/link-survey-email";
import { ResponseFinishedEmail } from "./emails/survey/response-finished-email";
export const IS_SMTP_CONFIGURED = Boolean(SMTP_HOST && SMTP_PORT);
const legalProps: TEmailTemplateLegalProps = {
privacyUrl: PRIVACY_URL || undefined,
termsUrl: TERMS_URL || undefined,
imprintUrl: IMPRINT_URL || undefined,
imprintAddress: IMPRINT_ADDRESS || undefined,
};
interface SendEmailDataProps {
to: string;
replyTo?: string;
@@ -89,7 +103,7 @@ export const sendVerificationNewEmail = async (id: string, email: string): Promi
const token = createEmailChangeToken(id, email);
const verifyLink = `${WEBAPP_URL}/verify-email-change?token=${encodeURIComponent(token)}`;
const html = await render(await NewEmailVerification({ verifyLink }));
const html = await renderNewEmailVerification({ verifyLink, t, ...legalProps });
return await sendEmail({
to: email,
@@ -117,7 +131,12 @@ export const sendVerificationEmail = async ({
const verifyLink = `${WEBAPP_URL}/auth/verify?token=${encodeURIComponent(token)}`;
const verificationRequestLink = `${WEBAPP_URL}/auth/verification-requested?token=${encodeURIComponent(token)}`;
const html = await render(await VerificationEmail({ verificationRequestLink, verifyLink }));
const html = await renderVerificationEmail({
verificationRequestLink,
verifyLink,
t,
...legalProps,
});
return await sendEmail({
to: email,
@@ -140,7 +159,7 @@ export const sendForgotPasswordEmail = async (user: {
expiresIn: "1d",
});
const verifyLink = `${WEBAPP_URL}/auth/forgot-password/reset?token=${encodeURIComponent(token)}`;
const html = await render(await ForgotPasswordEmail({ verifyLink }));
const html = await renderForgotPasswordEmail({ verifyLink, t, ...legalProps });
return await sendEmail({
to: user.email,
subject: t("emails.forgot_password_email_subject"),
@@ -150,7 +169,7 @@ export const sendForgotPasswordEmail = async (user: {
export const sendPasswordResetNotifyEmail = async (user: { email: string }): Promise<boolean> => {
const t = await getTranslate();
const html = await render(await PasswordResetNotifyEmail());
const html = await renderPasswordResetNotifyEmail({ t, ...legalProps });
return await sendEmail({
to: user.email,
subject: t("emails.password_reset_notify_email_subject"),
@@ -171,7 +190,7 @@ export const sendInviteMemberEmail = async (
const verifyLink = `${WEBAPP_URL}/invite?token=${encodeURIComponent(token)}`;
const html = await render(await InviteEmail({ inviteeName, inviterName, verifyLink }));
const html = await renderInviteEmail({ inviteeName, inviterName, verifyLink, t, ...legalProps });
return await sendEmail({
to: email,
subject: t("emails.invite_member_email_subject"),
@@ -185,7 +204,7 @@ export const sendInviteAcceptedEmail = async (
email: string
): Promise<void> => {
const t = await getTranslate();
const html = await render(await InviteAcceptedEmail({ inviteeName, inviterName }));
const html = await renderInviteAcceptedEmail({ inviteeName, inviterName, t, ...legalProps });
await sendEmail({
to: email,
subject: t("emails.invite_accepted_email_subject"),
@@ -208,16 +227,20 @@ export const sendResponseFinishedEmail = async (
throw new Error("Organization not found");
}
const html = await render(
await ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
})
);
// Pre-process the element response mapping before passing to email
const elements = getElementResponseMapping(survey, response);
const html = await renderResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements,
t,
...legalProps,
});
await sendEmail({
to: email,
@@ -241,7 +264,13 @@ export const sendEmbedSurveyPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const html = await render(await EmbedSurveyPreviewEmail({ html: innerHtml, environmentId, logoUrl }));
const html = await renderEmbedSurveyPreviewEmail({
html: innerHtml,
environmentId,
logoUrl,
t,
...legalProps,
});
return await sendEmail({
to,
subject: t("emails.embed_survey_preview_email_subject"),
@@ -255,7 +284,12 @@ export const sendEmailCustomizationPreviewEmail = async (
logoUrl?: string
): Promise<boolean> => {
const t = await getTranslate();
const emailHtmlBody = await render(await EmailCustomizationPreviewEmail({ userName, logoUrl }));
const emailHtmlBody = await renderEmailCustomizationPreviewEmail({
userName,
logoUrl,
t,
...legalProps,
});
return await sendEmail({
to,
@@ -280,7 +314,7 @@ export const sendLinkSurveyToVerifiedEmail = async (data: TLinkSurveyEmailData):
};
const surveyLink = getSurveyLink();
const html = await render(await LinkSurveyEmail({ surveyName, surveyLink, logoUrl }));
const html = await renderLinkSurveyEmail({ surveyName, surveyLink, logoUrl, t, ...legalProps });
return await sendEmail({
to: data.email,
subject: t("emails.verified_link_survey_email_subject"),

View File

@@ -24,6 +24,7 @@ import {
getOrganizationOwnerCount,
} from "@/modules/organization/settings/teams/lib/membership";
import { deleteInvite, getInvite, inviteUser, resendInvite } from "./lib/invite";
import { enrollInSecurityUpdates } from "./lib/security-updates";
const ZDeleteInviteAction = z.object({
inviteId: ZUuid,
@@ -387,3 +388,39 @@ export const leaveOrganizationAction = authenticatedActionClient.schema(ZLeaveOr
}
)
);
const ZEnrollSecurityUpdatesAction = z.object({
organizationId: ZId,
});
export const enrollSecurityUpdatesAction = authenticatedActionClient
.schema(ZEnrollSecurityUpdatesAction)
.action(async ({ ctx, parsedInput }) => {
// Ensure this is only called for self-hosted instances
if (IS_FORMBRICKS_CLOUD) {
throw new OperationNotAllowedError(
"Security updates enrollment is only available for self-hosted instances"
);
}
// Only owners can enroll in security updates
await checkAuthorizationUpdated({
userId: ctx.user.id,
organizationId: parsedInput.organizationId,
access: [
{
type: "organization",
roles: ["owner"],
},
],
});
// Enroll with the current user's email
const result = await enrollInSecurityUpdates(ctx.user.email);
if (!result.success) {
throw new Error("Failed to enroll in security updates");
}
return { success: true };
});

View File

@@ -0,0 +1,98 @@
"use client";
import { ShieldCheckIcon } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import toast from "react-hot-toast";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/cn";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { enrollSecurityUpdatesAction } from "@/modules/organization/settings/teams/actions";
import { TSecurityUpdatesStatus } from "@/modules/organization/settings/teams/lib/security-updates";
import { Button } from "@/modules/ui/components/button";
import { H4, P } from "@/modules/ui/components/typography";
interface SecurityUpdatesCardProps {
organizationId: string;
userEmail: string;
securityUpdatesStatus: TSecurityUpdatesStatus;
}
export const SecurityUpdatesCard = ({
organizationId,
userEmail,
securityUpdatesStatus,
}: SecurityUpdatesCardProps) => {
const router = useRouter();
const { t } = useTranslation();
const [isEnrolling, setIsEnrolling] = useState(false);
const handleEnroll = async () => {
setIsEnrolling(true);
try {
const result = await enrollSecurityUpdatesAction({ organizationId });
if (result?.data?.success) {
toast.success(t("environments.settings.teams.security_updates_enrolled_successfully"));
router.refresh();
} else {
const errorMessage = getFormattedErrorMessage(result);
toast.error(errorMessage);
}
} catch (error) {
toast.error(t("common.something_went_wrong_please_try_again"));
console.error(error);
} finally {
setIsEnrolling(false);
}
};
const isEnrolled = securityUpdatesStatus.enrolled;
return (
<div
className={cn(
"relative my-4 w-full max-w-4xl rounded-xl border bg-white shadow-sm",
isEnrolled ? "border-green-200 bg-green-50" : "border-slate-200"
)}>
<div className="flex items-start justify-between p-6">
<div className="flex items-start gap-4">
<div
className={cn(
"flex h-10 w-10 items-center justify-center rounded-full",
isEnrolled ? "bg-green-100" : "bg-slate-100"
)}>
<ShieldCheckIcon className={cn("h-5 w-5", isEnrolled ? "text-green-600" : "text-slate-600")} />
</div>
<div className="flex flex-col gap-1">
<H4 className="font-medium tracking-normal">
{t("environments.settings.teams.security_updates_title")}
</H4>
<P className="!mt-0 text-sm text-slate-500">
{isEnrolled
? t("environments.settings.teams.security_updates_enrolled_description", {
email: securityUpdatesStatus.email || userEmail,
})
: t("environments.settings.teams.security_updates_description")}
</P>
</div>
</div>
{!isEnrolled && (
<Button onClick={handleEnroll} disabled={isEnrolling} className="shrink-0">
{isEnrolling
? t("environments.settings.teams.security_updates_enrolling")
: t("environments.settings.teams.security_updates_enroll")}
</Button>
)}
{isEnrolled && (
<div className="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="text-sm font-medium text-green-700">
{t("environments.settings.teams.security_updates_enrolled")}
</span>
</div>
)}
</div>
</div>
);
};

View File

@@ -0,0 +1,68 @@
"use server";
import { getInstanceId } from "@/lib/instance";
export type TSecurityUpdatesStatus = {
enrolled: boolean;
email?: string;
};
/**
* Checks if the current instance is enrolled in security updates.
*
* TODO: Replace with actual EE server call
* GET /security-updates/status?instanceId=xxx
*
* @returns The enrollment status and email if enrolled
*/
export const getSecurityUpdatesStatus = async (): Promise<TSecurityUpdatesStatus> => {
const instanceId = await getInstanceId();
if (!instanceId) {
return { enrolled: false };
}
// TODO: Replace with actual EE server call
// const response = await fetch(`${EE_SERVER_URL}/instances/${instanceId}/security-updates`);
// if (!response.ok) {
// return { enrolled: false };
// }
// return await response.json();
// Mock: Always return not enrolled for now
return { enrolled: false };
};
/**
* Enrolls the current instance in security updates.
*
* TODO: Replace with actual EE server call
* POST /security-updates/enroll { instanceId, email }
*
* @param email - The email address to receive security updates
* @returns Success status
*/
export const enrollInSecurityUpdates = async (email: string): Promise<{ success: boolean }> => {
const instanceId = await getInstanceId();
if (!instanceId) {
throw new Error("Instance ID not found");
}
// TODO: Replace with actual EE server call
// const response = await fetch(`${EE_SERVER_URL}/instances/${instanceId}/security-updates`, {
// method: "POST",
// headers: { "Content-Type": "application/json" },
// body: JSON.stringify({ instanceId, email }),
// });
//
// if (!response.ok) {
// throw new Error("Failed to enroll in security updates");
// }
//
// return await response.json();
// Mock: Always succeed for now
console.log(`[Mock] Enrolling instance ${instanceId} with email ${email}`);
return { success: true };
};

View File

@@ -1,20 +1,25 @@
import { OrganizationSettingsNavbar } from "@/app/(app)/environments/[environmentId]/settings/(organization)/components/OrganizationSettingsNavbar";
import { IS_FORMBRICKS_CLOUD, USER_MANAGEMENT_MINIMUM_ROLE } from "@/lib/constants";
import { getUserManagementAccess } from "@/lib/membership/utils";
import { getUser } from "@/lib/user/service";
import { getTranslate } from "@/lingodotdev/server";
import { getAccessControlPermission } from "@/modules/ee/license-check/lib/utils";
import { getTeamsWhereUserIsAdmin } from "@/modules/ee/teams/lib/roles";
import { TeamsView } from "@/modules/ee/teams/team-list/components/teams-view";
import { getEnvironmentAuth } from "@/modules/environments/lib/utils";
import { MembersView } from "@/modules/organization/settings/teams/components/members-view";
import { SecurityUpdatesCard } from "@/modules/organization/settings/teams/components/security-updates-card";
import { getSecurityUpdatesStatus } from "@/modules/organization/settings/teams/lib/security-updates";
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();
const { session, currentUserMembership, organization } = await getEnvironmentAuth(params.environmentId);
const { session, currentUserMembership, organization, isOwner } = await getEnvironmentAuth(
params.environmentId
);
const isAccessControlAllowed = await getAccessControlPermission(organization.billing.plan);
@@ -32,6 +37,12 @@ export const TeamsPage = async (props) => {
const hasUserManagementAccess =
hasStandardUserManagementAccess || (isAccessControlAllowed && isTeamAdminUser);
// Fetch security updates status for self-hosted instances only (owners only)
const shouldShowSecurityUpdates = !IS_FORMBRICKS_CLOUD && isOwner;
const [securityUpdatesStatus, user] = shouldShowSecurityUpdates
? await Promise.all([getSecurityUpdatesStatus(), getUser(session.user.id)])
: [null, null];
return (
<PageContentWrapper>
<PageHeader pageTitle={t("environments.settings.general.organization_settings")}>
@@ -42,6 +53,15 @@ export const TeamsPage = async (props) => {
activeId="teams"
/>
</PageHeader>
{securityUpdatesStatus && user && (
<SecurityUpdatesCard
organizationId={organization.id}
userEmail={user.email}
securityUpdatesStatus={securityUpdatesStatus}
/>
)}
<MembersView
membershipRole={currentUserMembership?.role}
organization={organization}

View File

@@ -22,6 +22,7 @@ interface EditWelcomeCardProps {
setSelectedLanguageCode: (languageCode: string) => void;
locale: TUserLocale;
isStorageConfigured: boolean;
isExternalUrlsAllowed?: boolean;
}
export const EditWelcomeCard = ({
@@ -34,6 +35,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode,
locale,
isStorageConfigured = true,
isExternalUrlsAllowed,
}: EditWelcomeCardProps) => {
const { t } = useTranslation();
@@ -65,7 +67,7 @@ export const EditWelcomeCard = ({
<div
className={cn(
open ? "bg-slate-50" : "",
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
)}>
<Hand className="h-4 w-4" />
@@ -135,6 +137,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
<div className="mt-3">
@@ -150,6 +153,7 @@ export const EditWelcomeCard = ({
setSelectedLanguageCode={setSelectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
@@ -170,6 +174,7 @@ export const EditWelcomeCard = ({
label={t("environments.surveys.edit.next_button_label")}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
</div>

View File

@@ -808,6 +808,7 @@ export const ElementsView = ({
selectedLanguageCode={selectedLanguageCode}
locale={locale}
isStorageConfigured={isStorageConfigured}
isExternalUrlsAllowed={isExternalUrlsAllowed}
/>
</div>
)}

View File

@@ -1,120 +0,0 @@
import { Column, Hr, Row, Text } from "@react-email/components";
import dompurify from "isomorphic-dompurify";
import React from "react";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { EmailTemplate } from "@/modules/email/components/email-template";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
interface FollowUpEmailProps {
readonly followUp: TSurveyFollowUp;
readonly logoUrl?: string;
readonly attachResponseData: boolean;
readonly includeVariables: boolean;
readonly includeHiddenFields: boolean;
readonly survey: TSurvey;
readonly response: TResponse;
}
export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JSX.Element> {
const { properties } = props.followUp.action;
let { body } = properties;
// Parse recall tags and replace with actual response values
body = parseRecallInfo(body, props.response.data, props.response.variables);
const elements = props.attachResponseData ? getElementResponseMapping(props.survey, props.response) : [];
const t = await getTranslate();
return (
<EmailTemplate logoUrl={props.logoUrl} t={t}>
<>
<div
dangerouslySetInnerHTML={{
__html: dompurify.sanitize(body, {
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
}),
}}
/>
{elements.length > 0 ? (
<>
<Hr />
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
</>
) : null}
{elements.map((e) => {
if (!e.response) return;
return (
<Row key={e.element}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">{e.element}</Text>
{renderEmailResponseValue(e.response, e.type, t, true)}
</Column>
</Row>
);
})}
{props.attachResponseData &&
props.includeVariables &&
props.survey.variables
.filter((variable) => {
const variableResponse = props.response.variables[variable.id];
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
const variableResponse = props.response.variables[variable.id];
return (
<Row key={variable.id}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{variable.type === "number"
? `${t("emails.number_variable")}: ${variable.name}`
: `${t("emails.text_variable")}: ${variable.name}`}
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{variableResponse}
</Text>
</Column>
</Row>
);
})}
{props.attachResponseData &&
props.includeHiddenFields &&
props.survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => {
const hiddenFieldResponse = props.response.data[hiddenFieldId] as string;
return (
<Row key={hiddenFieldId}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenFieldId}
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{hiddenFieldResponse}
</Text>
</Column>
</Row>
);
})}
</>
</EmailTemplate>
);
}

View File

@@ -1,9 +1,18 @@
import { render } from "@react-email/components";
import sanitizeHtml from "sanitize-html";
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
import {
ProcessedHiddenField,
ProcessedResponseElement,
ProcessedVariable,
renderFollowUpEmail,
} from "@formbricks/email";
import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL, TERMS_URL } from "@/lib/constants";
import { getElementResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { sendEmail } from "@/modules/email";
import { FollowUpEmail } from "@/modules/survey/follow-ups/components/follow-up-email";
export const sendFollowUpEmail = async ({
followUp,
@@ -28,21 +37,79 @@ export const sendFollowUpEmail = async ({
}): Promise<void> => {
const {
action: {
properties: { subject },
properties: { subject, body },
},
} = followUp;
const emailHtmlBody = await render(
await FollowUpEmail({
followUp,
logoUrl,
attachResponseData,
includeVariables,
includeHiddenFields,
survey,
response,
})
);
const t = await getTranslate();
// Process body: parse recall tags and sanitize HTML
const processedBody = sanitizeHtml(parseRecallInfo(body, response.data, response.variables), {
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
allowedAttributes: {
a: ["href", "rel", "target"],
"*": ["dir", "class"],
},
allowedSchemes: ["http", "https"],
allowedSchemesByTag: {
a: ["http", "https"],
},
});
// Process response data
const responseData: ProcessedResponseElement[] = attachResponseData
? getElementResponseMapping(survey, response).map((e) => ({
element: e.element,
response: e.response,
type: e.type,
}))
: [];
// Process variables
const variables: ProcessedVariable[] =
attachResponseData && includeVariables
? survey.variables
.filter((variable) => {
const variableResponse = response.variables[variable.id];
return (
(typeof variableResponse === "string" || typeof variableResponse === "number") &&
variableResponse !== undefined
);
})
.map((variable) => ({
id: variable.id,
name: variable.name,
type: variable.type,
value: response.variables[variable.id],
}))
: [];
// Process hidden fields
const hiddenFields: ProcessedHiddenField[] =
attachResponseData && includeHiddenFields
? (survey.hiddenFields.fieldIds
?.filter((hiddenFieldId) => {
const hiddenFieldResponse = response.data[hiddenFieldId];
return hiddenFieldResponse && typeof hiddenFieldResponse === "string";
})
.map((hiddenFieldId) => ({
id: hiddenFieldId,
value: response.data[hiddenFieldId] as string,
})) ?? [])
: [];
const emailHtmlBody = await renderFollowUpEmail({
body: processedBody,
responseData,
variables,
hiddenFields,
logoUrl,
t,
privacyUrl: PRIVACY_URL || undefined,
termsUrl: TERMS_URL || undefined,
imprintUrl: IMPRINT_URL || undefined,
imprintAddress: IMPRINT_ADDRESS || undefined,
});
await sendEmail({
to,

View File

@@ -39,7 +39,7 @@ export const TemplateContainerWithPreview = ({
{isTemplatePage && <MenuBar />}
<div className="relative z-0 flex flex-1 overflow-hidden">
<div className="flex-1 flex-col overflow-auto bg-slate-50">
<div className="mb-3 ml-6 mt-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<div className="mt-6 mb-3 ml-6 flex flex-col items-center justify-between md:flex-row md:items-end">
<h1 className="text-2xl font-bold text-slate-800">
{isTemplatePage
? t("environments.surveys.templates.create_a_new_survey")

View File

@@ -138,5 +138,15 @@ describe("File Input Utils", () => {
test("returns false for non-YouTube URLs", () => {
expect(checkForYoutubePrivacyMode("https://www.example.com")).toBe(false);
});
test("should return false for empty or whitespace-only string", () => {
expect(checkForYoutubePrivacyMode("")).toBe(false);
expect(checkForYoutubePrivacyMode(" ")).toBe(false);
});
test("should return false for non-string types", () => {
expect(checkForYoutubePrivacyMode(null as any)).toBe(false);
expect(checkForYoutubePrivacyMode(123 as any)).toBe(false);
});
});
});

View File

@@ -86,6 +86,10 @@ export const getAllowedFiles = async (
};
export const checkForYoutubePrivacyMode = (url: string): boolean => {
if (!url || typeof url !== "string" || url.trim() === "") {
return false;
}
try {
const parsedUrl = new URL(url);
return parsedUrl.host === "www.youtube-nocookie.com";

View File

@@ -225,10 +225,10 @@ export const PreviewSurvey = ({
)}>
{previewMode === "mobile" && (
<>
<p className="absolute left-0 top-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
<p className="absolute top-0 left-0 m-2 rounded bg-slate-100 px-2 py-1 text-xs text-slate-400">
Preview
</p>
<div className="absolute right-0 top-0 m-2">
<div className="absolute top-0 right-0 m-2">
<ResetProgressButton onClick={resetProgress} />
</div>
<MediaBackground
@@ -264,7 +264,7 @@ export const PreviewSurvey = ({
</Modal>
) : (
<div className="flex h-full w-full flex-col justify-center px-1">
<div className="absolute left-5 top-5">
<div className="absolute top-5 left-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}
@@ -371,7 +371,7 @@ export const PreviewSurvey = ({
styling={styling}
ContentRef={ContentRef as React.RefObject<HTMLDivElement>}
isEditorView>
<div className="absolute left-5 top-5">
<div className="absolute top-5 left-5">
{!styling.isLogoHidden && (
<ClientLogo
environmentId={environment.id}

View File

@@ -187,7 +187,7 @@ export const ThemeStylingPreviewSurvey = ({
ContentRef={ContentRef as React.MutableRefObject<HTMLDivElement> | null}
isEditorView>
{!project.styling?.isLogoHidden && (
<button className="absolute left-5 top-5" onClick={scrollToEditLogoSection}>
<button className="absolute top-5 left-5" onClick={scrollToEditLogoSection}>
<ClientLogo projectLogo={project.logo} previewSurvey />
</button>
)}

View File

@@ -442,7 +442,4 @@ const sentryOptions = {
// Runtime Sentry reporting still depends on DSN being set via environment variables
const exportConfig = process.env.SENTRY_AUTH_TOKEN ? withSentryConfig(nextConfig, sentryOptions) : nextConfig;
console.log("BASE PATH", nextConfig.basePath);
export default exportConfig;

View File

@@ -29,6 +29,7 @@
"@dnd-kit/utilities": "3.2.2",
"@formbricks/cache": "workspace:*",
"@formbricks/database": "workspace:*",
"@formbricks/email": "workspace:*",
"@formbricks/i18n-utils": "workspace:*",
"@formbricks/js-core": "workspace:*",
"@formbricks/logger": "workspace:*",
@@ -70,10 +71,9 @@
"@radix-ui/react-toggle": "1.1.8",
"@radix-ui/react-toggle-group": "1.1.9",
"@radix-ui/react-tooltip": "1.2.6",
"@react-email/components": "0.0.38",
"@sentry/nextjs": "10.5.0",
"@tailwindcss/forms": "0.5.10",
"@t3-oss/env-nextjs": "0.13.4",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@tanstack/react-table": "8.21.3",
"@ungap/structured-clone": "1.3.0",
@@ -111,16 +111,17 @@
"prismjs": "1.30.0",
"qr-code-styling": "1.9.2",
"qrcode": "1.5.4",
"react-calendar": "5.1.0",
"react-colorful": "5.6.1",
"react-confetti": "6.4.0",
"react-day-picker": "9.6.7",
"react-hook-form": "7.56.2",
"react-hot-toast": "2.5.2",
"react-calendar": "5.1.0",
"react-i18next": "15.7.3",
"react-turnstile": "1.1.4",
"react-use": "17.6.0",
"redis": "4.7.0",
"sanitize-html": "2.17.0",
"server-only": "0.0.1",
"sharp": "0.34.1",
"stripe": "16.12.0",
@@ -148,6 +149,7 @@
"@types/nodemailer": "7.0.2",
"@types/papaparse": "5.3.15",
"@types/qrcode": "1.5.5",
"@types/sanitize-html": "2.16.0",
"@types/testing-library__react": "10.2.0",
"@types/ungap__structured-clone": "1.2.0",
"@vitest/coverage-v8": "3.1.3",

View File

@@ -221,6 +221,7 @@ vi.mock("@/lib/constants", () => ({
"zh-Hans-CN",
"es-ES",
"sv-SE",
"ru-RU",
],
DEFAULT_LOCALE: "en-US",
BREVO_API_KEY: "mock-brevo-api-key",

File diff suppressed because it is too large Load Diff

View File

@@ -186,25 +186,21 @@
]
},
{
"group": "XM",
"group": "Best Practices",
"pages": [
{
"group": "Best Practices",
"icon": "lightbulb",
"pages": [
"xm-and-surveys/xm/best-practices/contact-form",
"xm-and-surveys/xm/best-practices/headless-surveys",
"xm-and-surveys/xm/best-practices/docs-feedback",
"xm-and-surveys/xm/best-practices/feature-chaser",
"xm-and-surveys/xm/best-practices/feedback-box",
"xm-and-surveys/xm/best-practices/improve-email-content",
"xm-and-surveys/xm/best-practices/interview-prompt",
"xm-and-surveys/xm/best-practices/cancel-subscription",
"xm-and-surveys/xm/best-practices/pmf-survey",
"xm-and-surveys/xm/best-practices/quiz-time",
"xm-and-surveys/xm/best-practices/improve-trial-cr"
]
}
"xm-and-surveys/xm/best-practices/understanding-survey-types",
"xm-and-surveys/xm/best-practices/contact-form",
"xm-and-surveys/xm/best-practices/headless-surveys",
"xm-and-surveys/xm/best-practices/docs-feedback",
"xm-and-surveys/xm/best-practices/feature-chaser",
"xm-and-surveys/xm/best-practices/feedback-box",
"xm-and-surveys/xm/best-practices/improve-email-content",
"xm-and-surveys/xm/best-practices/interview-prompt",
"xm-and-surveys/xm/best-practices/cancel-subscription",
"xm-and-surveys/xm/best-practices/pmf-survey",
"xm-and-surveys/xm/best-practices/quiz-time",
"xm-and-surveys/xm/best-practices/improve-trial-cr",
"xm-and-surveys/xm/best-practices/research-panel"
]
}
],

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

View File

@@ -40,12 +40,9 @@ icon: "code"
</Tip>
<Note>
**Important**: Any changes to actions, surveys, or environment configuration will take about 1 minute to
reflect in your app/website running the formbricks sdk with debug mode enabled due to server-side caching.
This includes new actions, modified action configurations, and survey trigger updates. For quick updates
during development and testing, you can enable [Debug
Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode) in your SDK
configuration.
**Important**: Any changes to actions, surveys, or environment configuration will take **up to 1 minute** to
reflect in your app/website due to server-side caching. This includes new actions, modified action
configurations, and survey trigger updates.
</Note>
## **Setting Up No-Code Actions**

View File

@@ -59,18 +59,36 @@ sidebarTitle: "Quickstart"
</Step>
<Step title="Publish the survey">
Publish the survey to make it available for the SDK to pull into the website or app where you want to show it.
</Step>
<Step title="Publish the survey">
Publish the survey to make it available for the SDK to pull into the website or app where you want to show
it.
</Step>
<Step title="Understand caching behavior">
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks
on websites with millions of visitors without high hosting cost.
<Note>
**Important**: Any changes to surveys, action classes, project settings, or environment configuration will
take **up to 1 minute** to reflect in your app/website due to server-side caching. This includes survey
modifications, new triggers, styling changes, and other updates.
</Note>
For troubleshooting, you can enable [Debug
Mode](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode) to see detailed logs
in your browser console.
</Step>
<Step title="Check browser compatibility">
Formbricks is designed to work with all modern browsers. Please ensure your users are using a supported version:
| Browser | Minimum Version | Release Date |
| :--- | :--- | :--- |
| **Chrome** | 111+ | March 2023 |
| **Edge** | 111+ | March 2023 |
| **Firefox** | 128+ | July 2024 |
| **Safari** | 16.4+ | March 2023 |
*Internet Explorer is not supported.*
<Step title="Enable debug mode in website / app">
For better scalability, we cache the request the SDK makes to the server. This allows you to use Formbricks on websites with millions of visitors without high hosting cost. On the downside, there can be **up to a 5 minute delay** until the SDK pulls the newest surveys from the server.
<Note>
**Important**: Any changes to surveys, action classes, project settings, or environment configuration will take up to 5 minutes to reflect in debug mode in your app/website due to server-side caching. This includes survey modifications, new triggers, styling changes, and other updates.
</Note>
To avoid the delay during development and testing, please switch on the [Debug Mode.](/xm-and-surveys/surveys/website-app-surveys/framework-guides#activate-debug-mode)
</Step>
</Steps>

View File

@@ -0,0 +1,198 @@
---
title: "Panel Management"
description: "Build and manage your own research panel using Formbricks to collect profiling data, create targeted segments, and distribute personalized survey links to panel members."
icon: "users-rectangle"
---
## Purpose
A research panel is a pre-recruited group of participants who have agreed to take part in multiple surveys over time. Building your own panel enables you to:
- Conduct research with a well-understood audience
- Target specific demographics or user profiles
- Distribute surveys quickly without recruiting new participants each time
- Track participant engagement and responses over time
## Formbricks Approach
Formbricks provides all the tools you need to build and manage a research panel:
- **Profiling surveys** to collect participant demographics and preferences
- **Contact management** to store and organize panel member data
- **Segments** to group participants by attributes for targeted research
- **Personal links** to distribute unique survey links to specific panel members
## Overview
Building a research panel with Formbricks involves these key steps:
1. Create a profiling survey to collect panel member information
2. Export responses and prepare contact data
3. Upload contacts into Formbricks
4. Create segments to group panel members
5. Create research surveys and distribute personal links
## Step-by-step guide
<Steps>
<Step title="Create a profiling survey">
Start by creating a survey to collect essential information about your panel members. This profiling survey should gather:
- **Contact information**: Email, name, and any identifiers you need
- **Demographics**: Age, location, occupation, etc.
- **Preferences**: Product usage, interests, or other relevant attributes
To create your profiling survey:
1. Go to [app.formbricks.com](https://app.formbricks.com) and click **Create Survey**
2. Choose a blank survey or start from a template
3. Add questions to collect the data you need for segmentation
4. Include a **Contact Info** question type to capture email addresses
![Contact Info question type](/images/xm-and-surveys/core-features/question-type/contact-info.webp)
</Step>
<Step title="Collect profiling responses">
Distribute your profiling survey and collect responses from potential panel members. You can share the survey via:
- Direct link
- Email
- Social media
- Your website
Wait until you have collected a sufficient number of responses to build your panel.
</Step>
<Step title="Download the responses CSV">
Once you have collected responses, export them for processing:
1. Navigate to your profiling survey's **Summary** page
2. Click the **Download** button
3. Select **CSV** format
4. Save the file to your computer
![Download responses CSV](/images/xm-and-surveys/xm/best-practices/panel-management/download-responses.webp)
This CSV contains all the profiling data from your respondents.
</Step>
<Step title="Download the example contact upload CSV">
To understand the required format for uploading contacts, download the example CSV:
1. Go to the **Contacts** section in your project
2. Click **Upload Contacts**
3. Download the **Example CSV** to see the required column structure
![Download example contacts CSV](/images/xm-and-surveys/xm/best-practices/panel-management/download-example-contacts.webp)
The example CSV shows you the format Formbricks expects for contact uploads, including required fields like email and optional attribute columns.
</Step>
<Step title="Map profiling responses to contact attributes">
Transform your profiling survey responses into the contact upload format:
1. Open both CSV files (responses and example template)
2. Create a new spreadsheet matching the upload template structure
3. Map each profiling survey question to a contact attribute column:
- Email → `email` column
- Name → `firstName`, `lastName` columns
- Other answers → Custom attribute columns
Example mapping:
| Survey Question | Contact Attribute |
|----------------|-------------------|
| "What is your email?" | `email` |
| "What is your name?" | `firstName`, `lastName` |
| "What industry do you work in?" | `industry` |
| "How large is your company?" | `companySize` |
| "What is your job title?" | `jobTitle` |
</Step>
<Step title="Upload contacts to Formbricks">
Import your panel members into Formbricks:
1. Go to the **Contacts** section
2. Click **Upload Contacts**
3. Select your prepared CSV file
4. Review the attribute mapping
5. Complete the upload
Your panel members are now stored in Formbricks with their profiling attributes.
</Step>
<Step title="Create segments for targeting">
Group your panel members into segments based on their attributes:
1. Go to the **Contacts** tab
2. Click **Create Segment**
![Create a new segment](/images/xm-and-surveys/surveys/website-app-surveys/targeting/contacts.webp)
3. Define filter conditions based on the attributes you collected:
- Example: `industry` equals "Technology" AND `companySize` equals "50-200"
![Attribute filter](/images/xm-and-surveys/surveys/website-app-surveys/targeting/attribute-filter.webp)
4. Name your segment descriptively (e.g., "Tech SMB Professionals")
5. Save the segment
Create multiple segments for different research needs. Learn more about [Advanced Targeting](/xm-and-surveys/surveys/website-app-surveys/advanced-targeting) for detailed segmentation options.
</Step>
<Step title="Create a research survey">
Now create the survey you want to distribute to your panel:
1. Click **Create Survey**
2. Design your research survey with the questions you need
3. Configure survey settings as needed
4. Publish the survey
</Step>
<Step title="Generate personal links for your segment">
Distribute unique survey links to your panel segment:
1. Navigate to your research survey's **Summary** page
2. Click **Share survey**
3. Select the **Personal Links** tab
4. Choose the segment you want to survey from the dropdown
5. Optionally set an expiry date for the links
6. Click **Generate & download links**
![Personal Links](/images/xm-and-surveys/xm/best-practices/panel-management/personal-links.webp)
You'll receive a CSV file containing unique survey links for each panel member in the segment. Learn more about [Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links).
</Step>
<Step title="Distribute your survey">
Send the personal links to your panel members using your preferred method:
- Email marketing platform
- Direct email
- SMS
- Any other communication channel
Each panel member receives their unique link, and their responses will be automatically attributed to their contact record.
</Step>
</Steps>
## Benefits of this approach
| Benefit | Description |
|---------|-------------|
| **Response attribution** | Know exactly who responded to each survey |
| **Targeted research** | Survey specific segments without bothering others |
| **Panel management** | Maintain a centralized database of research participants |
| **Reusability** | Use the same panel for multiple research projects |
| **Data enrichment** | Build up participant profiles over time with each survey |
## Next steps
- [Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links) - Learn more about generating and managing personal survey links
- [Advanced Targeting](/xm-and-surveys/surveys/website-app-surveys/advanced-targeting) - Explore detailed segmentation options
- [Hidden Fields](/xm-and-surveys/surveys/general-features/hidden-fields) - Pass additional data into surveys via URL parameters

View File

@@ -0,0 +1,134 @@
---
title: "Understanding Formbricks Survey Types"
sidebarTitle: "Survey Types 101"
description: "This guide explains the differences between Formbricks Link Surveys and Website & App Surveys to help you choose the right option."
icon: "road-circle-check"
---
## Quick comparison
| Feature | Link Surveys | Website & App Surveys |
|---------|--------------|----------------------|
| **Best for** | Known touchpoints | Behavior-based intercepts |
| **SDK required** | No | Yes |
| **Delivery** | Email, SMS, push, or webview | Triggered in-app by SDK |
| **Segmentation** | You control externally | Built-in via Formbricks |
| **Survey fatigue prevention** | You control externally | Automatic |
| **User identification** | [Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links) | [SDK user attributes](/xm-and-surveys/surveys/website-app-surveys/user-identification) |
## Link Surveys
Also known as **invitation-based surveys**. No SDK needed. You control when and who receives the survey, often through CRMs, marketing tools, or similar platforms.
![Link survey example](/images/xm-and-surveys/xm/best-practices/link-surveys-vs-in-app-surveys/link-surveys-example.webp)
### Delivery channels
- Email
- SMS
- Push notification
- In-app webview
### URL features
- **[Hidden Fields](/xm-and-surveys/surveys/general-features/hidden-fields)**: Pass user ID, order ID, or any identifier
- **[Data prefilling](/xm-and-surveys/surveys/link-surveys/data-prefilling)**: Pre-populate answers and optionally skip prefilled questions
- **[Personal Links](/xm-and-surveys/surveys/link-surveys/personal-links)**: Generate unique survey links for individual contacts
### Webview with Embed mode
For mobile apps, you can display Link Surveys in a webview with full control over when the survey appears. Your app decides the timing—open the webview when the moment is right.
**Enable Embed mode** by appending `?embed=true` to the survey URL:
```
https://app.formbricks.com/s/<surveyId>?embed=true&userId=123
```
**Listen for events** to close the webview automatically:
```javascript
window.addEventListener("message", (event) => {
if (event.data === "formbricksSurveyCompleted") {
closeWebView();
}
});
```
This approach gives you full control:
- **When**: Your app opens the webview at the right moment (after purchase, case closed, etc.)
- **Who**: Personal links allow you to have unique links for identified contacts.
- **Display**: Survey renders inside your app
- **Dismissal**: Close the webview on completion or user exit
Learn more in the [Embed Surveys guide](/xm-and-surveys/surveys/link-surveys/embed-surveys).
### When to use
- You already decide externally who should receive which survey
- Survey timing is tied to a known event such as a click on a link or button and in **each case**, you want to show the survey
- You want fastest approach to production
- Your platform doesn't have a supported SDK
- You don't need Formbricks attribute-based targeting & survey fatigue controls
## Website & App Surveys (SDK)
Also known as **intercept surveys**. Requires SDK integration. Formbricks handles targeting, triggering, and survey fatigue.
![In-app survey example](/images/xm-and-surveys/xm/best-practices/link-surveys-vs-in-app-surveys/in-app-surveys.webp)
### In-app features
**[Action tracking](/xm-and-surveys/surveys/website-app-surveys/actions)**
Track in-app events and trigger surveys based on them:
```javascript
formbricks.track("action_name");
```
**Built-in survey cooldown**
- Global waiting time (e.g., show max 1 survey every 7 days per user)
- Per-survey overrides for high-priority surveys
**[Segment-based targeting](/xm-and-surveys/surveys/website-app-surveys/advanced-targeting)**
Target surveys based on user attributes (plan type, language, feature flags).
**In-app display**
Surveys appear inside your app as modals or slide-ins.
### When to use
- You want Formbricks to handle who sees which survey
- Surveys should trigger based on user behavior (page views, clicks, custom events)
- You want automatic survey fatigue prevention
- You need percentage-based sampling (e.g., show to 10% of users)
## Decision guide
<AccordionGroup>
<Accordion title="Do you control survey triggering externally?">
Use **Link-based Surveys**. Send the URL when the moment is right, even for in-app surveys.Pass context via Hidden Fields.
</Accordion>
<Accordion title="Should surveys trigger based on in-app behavior?">
Use **Website & App Surveys**. The SDK tracks actions and triggers surveys automatically.
</Accordion>
<Accordion title="Do you want automatic survey fatigue prevention?">
Use **Website & App Surveys**. Built-in cooldown prevents over-surveying.
</Accordion>
<Accordion title="Need the fastest path to production?">
Start with **Link Surveys**. No SDK integration needed. Add SDK later if you need behavior-based triggers.
</Accordion>
</AccordionGroup>
---
**Need help?** [Join us in GitHub Discussions](https://github.com/formbricks/formbricks/discussions)

View File

@@ -0,0 +1,4 @@
module.exports = {
extends: ["@formbricks/eslint-config/legacy-react.js"],
parser: "@typescript-eslint/parser",
};

86
packages/email/README.md Normal file
View File

@@ -0,0 +1,86 @@
# @formbricks/emails
Email templates for Formbricks with React Email preview server.
## Purpose
This package provides email templates for visual QA and preview. It includes:
- Email templates (auth, invite, survey, general)
- Shared email UI components
- Mock translation utilities for preview
- Example data for template rendering
- Tailwind CSS for styling with full intellisense support
## Development
### Preview Server
Run the React Email preview server:
```bash
pnpm dev
```
Visit `localhost:3456` to preview all email templates with mock data.
### Styling
The package uses Tailwind CSS via `@react-email/components`. Tailwind intellisense is configured and should work automatically in your IDE. The config files are:
- `tailwind.config.js` - Tailwind configuration for intellisense
- `postcss.config.js` - PostCSS configuration
### Path Aliases
Use `@/` prefix for clean imports:
```typescript
import { FollowUpEmail } from "@/emails/survey/follow-up-email";
import { EmailTemplate } from "@/src/components/email-template";
import { mockT } from "@/src/lib/mock-translate";
```
## Usage in Production
The web app imports render helper functions from this package:
```typescript
import { renderVerificationEmail } from "@formbricks/email";
// Pass real translation function and data
const html = await renderVerificationEmail({
verifyLink,
verificationRequestLink,
t, // Real i18n function from getTranslate()
});
```
For complex emails with pre-processing:
```typescript
import { renderResponseFinishedEmail } from "@formbricks/email";
import { getElementResponseMapping } from "@/lib/responses";
// Pre-process data before rendering
const elements = getElementResponseMapping(survey, response);
const html = await renderResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
elements, // Pre-processed data
t,
});
```
## Architecture
- **Preview Mode**: Templates use mock `t()` function and example data for visual QA
- **Production Mode**: Web app passes real `t()` function and pre-processed data
- **Render Functions**: Typed helper functions abstract `@react-email/render` from web app
- **No Business Logic**: SMTP, i18n, JWT, database queries, and data processing stay in web app
- **Clean Separation**: Web app processes data → Email package renders HTML

View File

@@ -0,0 +1,36 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailButton } from "../../src/components/email-button";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface ForgotPasswordEmailProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly t?: TFunction;
}
export function ForgotPasswordEmail({
verifyLink,
t = mockT,
...legalProps
}: ForgotPasswordEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<Container>
<Heading>{t("emails.forgot_password_email_heading")}</Heading>
<Text className="text-sm">{t("emails.forgot_password_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.forgot_password_email_change_password")} />
<Text className="text-sm font-bold">{t("emails.forgot_password_email_link_valid_for_24_hours")}</Text>
<Text className="mb-0 text-sm">{t("emails.forgot_password_email_did_not_request")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default function ForgotPasswordEmailPreview(): React.JSX.Element {
return <ForgotPasswordEmail {...exampleData.forgotPasswordEmail} />;
}

View File

@@ -0,0 +1,40 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import { EmailButton } from "../../src/components/email-button";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface NewEmailVerificationProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly t?: TFunction;
}
export function NewEmailVerification({
verifyLink,
t = mockT,
...legalProps
}: NewEmailVerificationProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.new_email_verification_text")}</Text>
<Text className="text-sm">{t("emails.verification_security_notice")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="text-sm break-all text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default function NewEmailVerificationPreview(): React.JSX.Element {
return <NewEmailVerification {...exampleData.newEmailVerification} />;
}

View File

@@ -0,0 +1,30 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface PasswordResetNotifyEmailProps extends TEmailTemplateLegalProps {
readonly t?: TFunction;
}
export function PasswordResetNotifyEmail({
t = mockT,
...legalProps
}: PasswordResetNotifyEmailProps = {}): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<Container>
<Heading>{t("emails.password_changed_email_heading")}</Heading>
<Text className="text-sm">{t("emails.password_changed_email_text")}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default function PasswordResetNotifyEmailPreview(): React.JSX.Element {
return <PasswordResetNotifyEmail {...exampleData.passwordResetNotifyEmail} />;
}

View File

@@ -1,28 +1,32 @@
import { Container, Heading, Link, Text } from "@react-email/components";
import React from "react";
import { getTranslate } from "@/lingodotdev/server";
import { EmailButton } from "../../components/email-button";
import { EmailFooter } from "../../components/email-footer";
import { EmailTemplate } from "../../components/email-template";
import { EmailButton } from "../../src/components/email-button";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface VerificationEmailProps {
verifyLink: string;
verificationRequestLink: string;
interface VerificationEmailProps extends TEmailTemplateLegalProps {
readonly verifyLink: string;
readonly verificationRequestLink: string;
readonly t?: TFunction;
}
export async function VerificationEmail({
export function VerificationEmail({
verifyLink,
verificationRequestLink,
}: VerificationEmailProps): Promise<React.JSX.Element> {
const t = await getTranslate();
t = mockT,
...legalProps
}: VerificationEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<EmailTemplate t={t} {...legalProps}>
<Container>
<Heading>{t("emails.verification_email_heading")}</Heading>
<Text className="text-sm">{t("emails.verification_email_text")}</Text>
<EmailButton href={verifyLink} label={t("emails.verification_email_verify_email")} />
<Text className="text-sm">{t("emails.verification_email_click_on_this_link")}</Text>
<Link className="break-all text-sm text-black" href={verifyLink}>
<Link className="text-sm break-all text-black" href={verifyLink}>
{verifyLink}
</Link>
<Text className="text-sm font-bold">{t("emails.verification_email_link_valid_for_24_hours")}</Text>
@@ -38,4 +42,6 @@ export async function VerificationEmail({
);
}
export default VerificationEmail;
export default function VerificationEmailPreview(): React.JSX.Element {
return <VerificationEmail {...exampleData.verificationEmail} />;
}

View File

@@ -0,0 +1,32 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface EmailCustomizationPreviewEmailProps extends TEmailTemplateLegalProps {
readonly userName: string;
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function EmailCustomizationPreviewEmail({
userName,
logoUrl,
t = mockT,
...legalProps
}: EmailCustomizationPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<Container>
<Heading>{t("emails.email_customization_preview_email_heading", { userName })}</Heading>
<Text className="text-sm">{t("emails.email_customization_preview_email_text")}</Text>
</Container>
</EmailTemplate>
);
}
export default function EmailCustomizationPreviewEmailPreview(): React.JSX.Element {
return <EmailCustomizationPreviewEmail {...exampleData.emailCustomizationPreviewEmail} />;
}

View File

@@ -0,0 +1,34 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface InviteAcceptedEmailProps extends TEmailTemplateLegalProps {
readonly inviterName: string;
readonly inviteeName: string;
readonly t?: TFunction;
}
export function InviteAcceptedEmail({
inviterName,
inviteeName,
t = mockT,
...legalProps
}: InviteAcceptedEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<Container>
<Heading>{t("emails.invite_accepted_email_heading", { inviterName })}</Heading>
<Text className="text-sm">{t("emails.invite_accepted_email_text", { inviteeName })}</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default function InviteAcceptedEmailPreview(): React.JSX.Element {
return <InviteAcceptedEmail {...exampleData.inviteAcceptedEmail} />;
}

View File

@@ -0,0 +1,38 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailButton } from "../../src/components/email-button";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface InviteEmailProps extends TEmailTemplateLegalProps {
readonly inviteeName: string;
readonly inviterName: string;
readonly verifyLink: string;
readonly t?: TFunction;
}
export function InviteEmail({
inviteeName,
inviterName,
verifyLink,
t = mockT,
...legalProps
}: InviteEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t} {...legalProps}>
<Container>
<Heading>{t("emails.invite_email_heading", { inviteeName })}</Heading>
<Text className="text-sm">{t("emails.invite_email_text", { inviterName })}</Text>
<EmailButton href={verifyLink} label={t("emails.invite_email_button_label")} />
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
export default function InviteEmailPreview(): React.JSX.Element {
return <InviteEmail {...exampleData.inviteEmail} />;
}

View File

@@ -0,0 +1,42 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface EmbedSurveyPreviewEmailProps extends TEmailTemplateLegalProps {
readonly html: string;
readonly environmentId: string;
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function EmbedSurveyPreviewEmail({
html,
environmentId,
logoUrl,
t = mockT,
...legalProps
}: EmbedSurveyPreviewEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<Container>
<Heading>{t("emails.embed_survey_preview_email_heading")}</Heading>
<Text className="text-sm">{t("emails.embed_survey_preview_email_text")}</Text>
<Text className="text-sm">
<b>{t("emails.embed_survey_preview_email_didnt_request")}</b>{" "}
{t("emails.embed_survey_preview_email_fight_spam")}
</Text>
<div className="text-sm" dangerouslySetInnerHTML={{ __html: html }} />
<Text className="text-center text-sm text-slate-700">
{t("emails.embed_survey_preview_email_environment_id")}: {environmentId}
</Text>
</Container>
</EmailTemplate>
);
}
export default function EmbedSurveyPreviewEmailPreview(): React.JSX.Element {
return <EmbedSurveyPreviewEmail {...exampleData.embedSurveyPreviewEmail} />;
}

View File

@@ -0,0 +1,86 @@
import { Column, Hr, Row, Text } from "@react-email/components";
import { EmailTemplate } from "../../src/components/email-template";
import { renderEmailResponseValue } from "../../src/lib/email-utils";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { ProcessedHiddenField, ProcessedResponseElement, ProcessedVariable } from "../../src/types/follow-up";
import { TFunction } from "../../src/types/translations";
export interface FollowUpEmailProps extends TEmailTemplateLegalProps {
readonly body: string; // Already processed HTML with recall tags replaced
readonly responseData?: ProcessedResponseElement[]; // Already mapped elements
readonly variables?: ProcessedVariable[]; // Already filtered variables
readonly hiddenFields?: ProcessedHiddenField[]; // Already filtered hidden fields
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function FollowUpEmail({
body,
responseData = [],
variables = [],
hiddenFields = [],
logoUrl,
t = mockT,
...legalProps
}: FollowUpEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<>
<div dangerouslySetInnerHTML={{ __html: body }} />
{responseData.length > 0 ? (
<>
<Hr />
<Text className="mb-4 text-base font-semibold text-slate-900">{t("emails.response_data")}</Text>
</>
) : null}
{responseData.map((e) => {
if (!e.response) return null;
return (
<Row key={e.element}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">{e.element}</Text>
{renderEmailResponseValue(e.response, e.type, t, true)}
</Column>
</Row>
);
})}
{variables.map((variable) => (
<Row key={variable.id}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{variable.type === "number"
? `${t("emails.number_variable")}: ${variable.name}`
: `${t("emails.text_variable")}: ${variable.name}`}
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{variable.value}
</Text>
</Column>
</Row>
))}
{hiddenFields.map((hiddenField) => (
<Row key={hiddenField.id}>
<Column className="w-full">
<Text className="mb-2 text-sm font-semibold text-slate-900">
{t("emails.hidden_field")}: {hiddenField.id}
</Text>
<Text className="mt-0 text-sm break-words whitespace-pre-wrap text-slate-700">
{hiddenField.value}
</Text>
</Column>
</Row>
))}
</>
</EmailTemplate>
);
}
export default function FollowUpEmailPreview(): React.JSX.Element {
return <FollowUpEmail {...(exampleData.followUpEmail as unknown as FollowUpEmailProps)} />;
}

View File

@@ -0,0 +1,43 @@
import { Container, Heading, Text } from "@react-email/components";
import { EmailButton } from "../../src/components/email-button";
import { EmailFooter } from "../../src/components/email-footer";
import { EmailTemplate } from "../../src/components/email-template";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { TFunction } from "../../src/types/translations";
interface LinkSurveyEmailProps extends TEmailTemplateLegalProps {
readonly surveyName: string;
readonly surveyLink: string;
readonly logoUrl?: string;
readonly t?: TFunction;
}
export function LinkSurveyEmail({
surveyName,
surveyLink,
logoUrl,
t = mockT,
...legalProps
}: LinkSurveyEmailProps): React.JSX.Element {
return (
<EmailTemplate logoUrl={logoUrl} t={t} {...legalProps}>
<Container>
<Heading>{t("emails.verification_email_hey")}</Heading>
<Text className="text-sm">{t("emails.verification_email_thanks")}</Text>
<Text className="text-sm">{t("emails.verification_email_to_fill_survey")}</Text>
<EmailButton href={surveyLink} label={t("emails.verification_email_take_survey")} />
<Text className="text-sm text-slate-400">
{t("emails.verification_email_survey_name")}: {surveyName}
</Text>
<EmailFooter t={t} />
</Container>
</EmailTemplate>
);
}
// Default export for preview server
export default function LinkSurveyEmailPreview(): React.JSX.Element {
return <LinkSurveyEmail {...exampleData.linkSurveyEmail} />;
}

View File

@@ -2,35 +2,52 @@ import { Column, Container, Heading, Hr, Link, Row, Section, Text } from "@react
import { FileDigitIcon, FileType2Icon } from "lucide-react";
import type { TOrganization } from "@formbricks/types/organizations";
import type { TResponse } from "@formbricks/types/responses";
import { type TSurvey } from "@formbricks/types/surveys/types";
import { getElementResponseMapping } from "@/lib/responses";
import { getTranslate } from "@/lingodotdev/server";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
import { EmailButton } from "../../components/email-button";
import { EmailTemplate } from "../../components/email-template";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import type { TSurvey } from "@formbricks/types/surveys/types";
import { EmailButton } from "../../src/components/email-button";
import { EmailTemplate } from "../../src/components/email-template";
import { renderEmailResponseValue } from "../../src/lib/email-utils";
import { exampleData } from "../../src/lib/example-data";
import { t as mockT } from "../../src/lib/mock-translate";
import { TEmailTemplateLegalProps } from "../../src/types/email";
import { ProcessedResponseElement } from "../../src/types/follow-up";
import { TFunction } from "../../src/types/translations";
interface ResponseFinishedEmailProps {
survey: TSurvey;
responseCount: number;
response: TResponse;
WEBAPP_URL: string;
environmentId: string;
organization: TOrganization;
export interface ResponseFinishedEmailProps extends TEmailTemplateLegalProps {
readonly survey: TSurvey;
readonly responseCount: number;
readonly response: TResponse;
readonly WEBAPP_URL: string;
readonly environmentId: string;
readonly organization: TOrganization;
readonly elements: ProcessedResponseElement[]; // Pre-processed data, not a function
readonly t?: TFunction;
}
export async function ResponseFinishedEmail({
const mockGetElementResponseMapping = (survey: TSurvey, response: TResponse) => {
// For preview, just return the response data as elements
return Object.entries(response.data)
.filter(([key]) => !survey.hiddenFields.fieldIds?.includes(key))
.map(([key, value]) => ({
element: key,
response: value as string | string[],
type: TSurveyElementTypeEnum.OpenText, // Default type for preview
}));
};
export function ResponseFinishedEmail({
survey,
responseCount,
response,
WEBAPP_URL,
environmentId,
organization,
}: ResponseFinishedEmailProps): Promise<React.JSX.Element> {
const elements = getElementResponseMapping(survey, response);
const t = await getTranslate();
elements,
t = mockT,
...legalProps
}: ResponseFinishedEmailProps): React.JSX.Element {
return (
<EmailTemplate t={t}>
<EmailTemplate t={t} {...legalProps}>
<Container>
<Row>
<Column>
@@ -42,7 +59,7 @@ export async function ResponseFinishedEmail({
</Text>
<Hr />
{elements.map((e) => {
if (!e.response) return;
if (!e.response) return null;
return (
<Row key={e.element}>
<Column className="w-full font-medium">
@@ -58,7 +75,6 @@ export async function ResponseFinishedEmail({
if (typeof variableResponse !== "string" && typeof variableResponse !== "number") {
return false;
}
return variableResponse !== undefined;
})
.map((variable) => {
@@ -158,3 +174,11 @@ function EyeOffIcon(): React.JSX.Element {
</svg>
);
}
// Default export for preview server
export default function ResponseFinishedEmailPreview(): React.JSX.Element {
const { survey, response, ...rest } = exampleData.responseFinishedEmail;
const elements = mockGetElementResponseMapping(survey, response);
return <ResponseFinishedEmail {...rest} survey={survey} response={response} elements={elements} />;
}

View File

@@ -0,0 +1,30 @@
{
"name": "@formbricks/email",
"version": "1.0.0",
"private": true,
"description": "Email templates for Formbricks with React Email preview server",
"type": "module",
"main": "src/index.ts",
"types": "src/index.ts",
"scripts": {
"dev": "email dev --port 3456",
"build": "tsc --noEmit",
"lint": "eslint src --fix --ext .ts,.tsx",
"clean": "rimraf .turbo node_modules dist"
},
"dependencies": {
"@react-email/components": "1.0.1",
"react-email": "5.0.8"
},
"devDependencies": {
"@formbricks/config-typescript": "workspace:*",
"@formbricks/eslint-config": "workspace:*",
"@formbricks/types": "workspace:*",
"@react-email/preview-server": "5.0.8",
"autoprefixer": "10.4.21",
"clsx": "2.1.1",
"postcss": "8.5.3",
"tailwind-merge": "3.2.0",
"tailwindcss": "3.4.17"
}
}

View File

@@ -1,9 +1,8 @@
import { Button } from "@react-email/components";
import React from "react";
interface EmailButtonProps {
label: string;
href: string;
readonly label: string;
readonly href: string;
}
export function EmailButton({ label, href }: EmailButtonProps): React.JSX.Element {

View File

@@ -1,23 +1,25 @@
import { Container } from "@react-email/components";
import { cn } from "@/lib/cn";
import { cn } from "../../src/lib/cn";
interface ElementHeaderProps {
headline: string;
subheader?: string;
className?: string;
readonly headline: string;
readonly subheader?: string;
readonly className?: string;
}
export function ElementHeader({ headline, subheader, className }: ElementHeaderProps): React.JSX.Element {
return (
<>
<Container className={cn("text-question-color m-0 block text-base font-semibold leading-6", className)}>
<Container className={cn("text-question-color m-0 block text-base leading-6 font-semibold", className)}>
<div dangerouslySetInnerHTML={{ __html: headline }} />
</Container>
{subheader && (
<Container className="text-question-color m-0 mt-2 block p-0 text-sm font-normal leading-6">
<Container className="text-question-color m-0 mt-2 block p-0 text-sm leading-6 font-normal">
<div dangerouslySetInnerHTML={{ __html: subheader }} />
</Container>
)}
</>
);
}
export default ElementHeader;

View File

@@ -1,6 +1,5 @@
import { Text } from "@react-email/components";
import { TFunction } from "i18next";
import React from "react";
import { TFunction } from "../types/translations";
export function EmailFooter({ t }: { t: TFunction }): React.JSX.Element {
return (

View File

@@ -1,22 +1,24 @@
import { Body, Container, Html, Img, Link, Section, Tailwind, Text } from "@react-email/components";
import { TFunction } from "i18next";
import React from "react";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
import { TEmailTemplateLegalProps } from "../types/email";
import { TFunction } from "../types/translations";
const fbLogoUrl = FB_LOGO_URL;
const fbLogoUrl = "https://app.formbricks.com/logo-transparent.png";
const logoLink = "https://formbricks.com?utm_source=email_header&utm_medium=email";
interface EmailTemplateProps {
interface EmailTemplateProps extends TEmailTemplateLegalProps {
readonly children: React.ReactNode;
readonly logoUrl?: string;
readonly t: TFunction;
}
export async function EmailTemplate({
export function EmailTemplate({
children,
logoUrl,
t,
}: EmailTemplateProps): Promise<React.JSX.Element> {
privacyUrl,
imprintUrl,
imprintAddress,
}: EmailTemplateProps): React.JSX.Element {
const isDefaultLogo = !logoUrl || logoUrl === fbLogoUrl;
return (
@@ -53,23 +55,23 @@ export async function EmailTemplate({
rel="noopener noreferrer">
{t("emails.email_template_text_1")}
</Link>
{IMPRINT_ADDRESS && (
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{IMPRINT_ADDRESS}</Text>
{imprintAddress && (
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">{imprintAddress}</Text>
)}
<Text className="m-0 text-sm font-normal text-slate-500 opacity-50">
{IMPRINT_URL && (
{imprintUrl && (
<Link
href={IMPRINT_URL}
href={imprintUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
{t("emails.imprint")}
</Link>
)}
{IMPRINT_URL && PRIVACY_URL && " • "}
{PRIVACY_URL && (
{imprintUrl && privacyUrl && " • "}
{privacyUrl && (
<Link
href={PRIVACY_URL}
href={privacyUrl}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-slate-500">
@@ -83,3 +85,5 @@ export async function EmailTemplate({
</Html>
);
}
export default EmailTemplate;

View File

@@ -0,0 +1,52 @@
export { VerificationEmail } from "../emails/auth/verification-email";
export { ForgotPasswordEmail } from "../emails/auth/forgot-password-email";
export { NewEmailVerification } from "../emails/auth/new-email-verification";
export { PasswordResetNotifyEmail } from "../emails/auth/password-reset-notify-email";
export { InviteEmail } from "../emails/invite/invite-email";
export { InviteAcceptedEmail } from "../emails/invite/invite-accepted-email";
export { LinkSurveyEmail } from "../emails/survey/link-survey-email";
export { EmbedSurveyPreviewEmail } from "../emails/survey/embed-survey-preview-email";
export { ResponseFinishedEmail } from "../emails/survey/response-finished-email";
export { EmailCustomizationPreviewEmail } from "../emails/general/email-customization-preview-email";
export { FollowUpEmail } from "../emails/survey/follow-up-email";
export { EmailButton } from "./components/email-button";
export { EmailFooter } from "./components/email-footer";
export { EmailTemplate } from "./components/email-template";
export { ElementHeader } from "./components/email-element-header";
export {
renderVerificationEmail,
renderForgotPasswordEmail,
renderNewEmailVerification,
renderPasswordResetNotifyEmail,
renderInviteEmail,
renderInviteAcceptedEmail,
renderLinkSurveyEmail,
renderEmbedSurveyPreviewEmail,
renderResponseFinishedEmail,
renderEmailCustomizationPreviewEmail,
renderFollowUpEmail,
} from "./lib/render";
export { render } from "@react-email/render";
export {
Body,
Button,
Column,
Container,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
Row,
Section,
Tailwind,
Text,
} from "@react-email/components";
export type { ProcessedHiddenField, ProcessedResponseElement, ProcessedVariable } from "./types/follow-up";

View File

@@ -0,0 +1,6 @@
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export const cn = (...inputs: ClassValue[]) => {
return twMerge(clsx(inputs));
};

View File

@@ -1,15 +1,26 @@
import { Column, Container, Img, Link, Row, Text } from "@react-email/components";
import { TFunction } from "i18next";
import { FileIcon } from "lucide-react";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { TFunction } from "../types/translations";
export const renderEmailResponseValue = async (
// Simplified version - just get the filename from URL
const getOriginalFileNameFromUrl = (url: string): string => {
try {
const urlObj = new URL(url);
const pathname = urlObj.pathname;
const filename = pathname.split("/").pop() || "file";
return decodeURIComponent(filename);
} catch {
return url.split("/").pop() || "file";
}
};
export const renderEmailResponseValue = (
response: string | string[],
questionType: TSurveyElementTypeEnum,
t: TFunction,
overrideFileUploadResponse = false
): Promise<React.JSX.Element> => {
): React.JSX.Element => {
switch (questionType) {
case TSurveyElementTypeEnum.FileUpload:
return (
@@ -65,6 +76,6 @@ export const renderEmailResponseValue = async (
);
default:
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response}</Text>;
return <Text className="mt-0 text-sm break-words whitespace-pre-wrap">{response as string}</Text>;
}
};

View File

@@ -0,0 +1,184 @@
// Mock data for email templates to use in React Email preview server
import { TOrganization } from "@formbricks/types/organizations";
import { TResponse } from "@formbricks/types/responses";
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
import { TSurvey } from "@formbricks/types/surveys/types";
export const exampleData = {
verificationEmail: {
verifyLink: "https://app.formbricks.com/auth/verify?token=example-verification-token",
verificationRequestLink: "https://app.formbricks.com/auth/verification-requested",
},
forgotPasswordEmail: {
verifyLink: "https://app.formbricks.com/auth/forgot-password/reset?token=example-reset-token",
},
newEmailVerification: {
verifyLink: "https://app.formbricks.com/verify-email-change?token=example-email-change-token",
},
passwordResetNotifyEmail: {
// No props needed
},
inviteEmail: {
inviteeName: "Jane Smith",
inviterName: "John Doe",
verifyLink: "https://app.formbricks.com/invite?token=example-invite-token",
},
inviteAcceptedEmail: {
inviterName: "John Doe",
inviteeName: "Jane Smith",
},
linkSurveyEmail: {
surveyName: "Customer Satisfaction Survey",
surveyLink:
"https://app.formbricks.com/s/example-survey-id?verify=example-token&suId=example-single-use-id",
},
embedSurveyPreviewEmail: {
html: '<div style="padding: 20px; background-color: #f3f4f6; border-radius: 8px;"><h3 style="margin-top: 0;">Example Survey Embed</h3><p>This is a preview of how your survey will look when embedded in an email.</p></div>',
environmentId: "clxyz123456789",
},
responseFinishedEmail: {
survey: {
id: "survey-123",
name: "Customer Feedback Survey",
variables: [
{
id: "var-1",
name: "Customer ID",
type: "text" as const,
},
],
hiddenFields: {
enabled: true,
fieldIds: ["userId"],
},
welcomeCard: {
enabled: false,
},
questions: [
{
id: "q1",
type: "openText" as const,
headline: { default: "What did you like most?" },
required: true,
inputType: "text" as const,
},
{
id: "q2",
type: "rating" as const,
headline: { default: "How would you rate your experience?" },
required: true,
scale: "number" as const,
range: 5,
},
],
endings: [],
styling: {},
createdBy: null,
} as unknown as TSurvey,
responseCount: 15,
response: {
id: "response-123",
createdAt: new Date(),
updatedAt: new Date(),
surveyId: "survey-123",
finished: true,
data: {
q1: "The customer service was excellent!",
q2: 5,
userId: "user-abc-123",
},
variables: {
"var-1": "CUST-456",
},
contactAttributes: {
email: "customer@example.com",
},
meta: {
userAgent: {},
url: "https://example.com",
},
tags: [],
notes: [],
ttc: {},
singleUseId: null,
language: "default",
displayId: null,
} as unknown as TResponse,
WEBAPP_URL: "https://app.formbricks.com",
environmentId: "env-123",
organization: {
id: "org-123",
name: "Acme Corporation",
createdAt: new Date(),
updatedAt: new Date(),
billing: {
stripeCustomerId: null,
subscriptionStatus: null,
features: {
inAppSurvey: { status: "active" as const, unlimited: true },
linkSurvey: { status: "active" as const, unlimited: true },
userTargeting: { status: "active" as const, unlimited: true },
},
limits: {
monthly: {
responses: 1000,
miu: 10000,
},
},
},
isAIEnabled: false,
} as unknown as TOrganization,
},
followUpEmail: {
body: "<p>Thank you for your feedback! We've received your response and will review it shortly.</p><p>Here's a summary of what you submitted:</p>",
responseData: [
{
element: "What did you like most?",
response: "The customer service was excellent!",
type: TSurveyElementTypeEnum.OpenText,
},
{
element: "How would you rate your experience?",
response: "5",
type: TSurveyElementTypeEnum.Rating,
},
],
variables: [
{
id: "var-1",
name: "Customer ID",
type: "text",
value: "CUST-456",
},
],
hiddenFields: [
{
id: "userId",
value: "user-abc-123",
},
],
},
emailCustomizationPreviewEmail: {
userName: "Alex Johnson",
},
legalProps: {
privacyUrl: "https://formbricks.com/privacy",
termsUrl: "https://formbricks.com/terms",
imprintUrl: "https://formbricks.com/imprint",
imprintAddress: "Formbricks GmbH, Example Street 123, 12345 Berlin, Germany",
},
};
export type ExampleDataKeys = keyof typeof exampleData;
export type ExampleData<K extends ExampleDataKeys> = (typeof exampleData)[K];

View File

@@ -0,0 +1,108 @@
// Mock translation function for React Email preview server
// Returns English strings extracted from apps/web/locales/en-US.json
type TranslationKey = string;
type TranslationValue = string;
const translations: Record<TranslationKey, TranslationValue> = {
"emails.accept": "Accept",
"emails.click_or_drag_to_upload_files": "Click or drag to upload files.",
"emails.email_customization_preview_email_heading": "Hey {userName}",
"emails.email_customization_preview_email_subject": "Formbricks Email Customization Preview",
"emails.email_customization_preview_email_text":
"This is an email preview to show you which logo will be rendered in the emails.",
"emails.email_footer_text_1": "Have a great day!",
"emails.email_footer_text_2": "The Formbricks Team",
"emails.email_template_text_1": "This email was sent via Formbricks.",
"emails.embed_survey_preview_email_didnt_request": "Didn't request this?",
"emails.embed_survey_preview_email_environment_id": "Environment ID",
"emails.embed_survey_preview_email_fight_spam":
"Help us fight spam and forward this mail to hola@formbricks.com",
"emails.embed_survey_preview_email_heading": "Preview Email Embed",
"emails.embed_survey_preview_email_subject": "Formbricks Email Survey Preview",
"emails.embed_survey_preview_email_text": "This is how the code snippet looks embedded into an email:",
"emails.forgot_password_email_change_password": "Change password",
"emails.forgot_password_email_did_not_request": "If you didn't request this, please ignore this email.",
"emails.forgot_password_email_heading": "Change password",
"emails.forgot_password_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"emails.forgot_password_email_subject": "Reset your Formbricks password",
"emails.forgot_password_email_text":
"You have requested a link to change your password. You can do this by clicking the link below:",
"emails.hidden_field": "Hidden field",
"emails.invite_accepted_email_heading": "Hey",
"emails.invite_accepted_email_subject": "You've got a new organization member!",
"emails.invite_accepted_email_text_par1": "Just letting you know that",
"emails.invite_accepted_email_text_par2": "accepted your invitation. Have fun collaborating!",
"emails.invite_email_button_label": "Join organization",
"emails.invite_email_heading": "Hey",
"emails.invite_email_text_par1": "Your colleague",
"emails.invite_email_text_par2":
"invited you to join them at Formbricks. To accept the invitation, please click the link below:",
"emails.invite_member_email_subject": "You're invited to collaborate on Formbricks!",
"emails.new_email_verification_text": "To verify your new email address, please click the button below:",
"emails.number_variable": "Number variable",
"emails.password_changed_email_heading": "Password changed",
"emails.password_changed_email_text": "Your password has been changed successfully.",
"emails.password_reset_notify_email_subject": "Your Formbricks password has been changed",
"emails.reject": "Reject",
"emails.render_email_response_value_file_upload_response_link_not_included":
"Link to uploaded file is not included for data privacy reasons",
"emails.response_data": "Response data",
"emails.response_finished_email_subject": "A response for {surveyName} was completed ✅",
"emails.response_finished_email_subject_with_email":
"{personEmail} just completed your {surveyName} survey ✅",
"emails.schedule_your_meeting": "Schedule your meeting",
"emails.select_a_date": "Select a date",
"emails.survey_response_finished_email_congrats":
"Congrats, you received a new response to your survey! Someone just completed your survey: {surveyName}",
"emails.survey_response_finished_email_dont_want_notifications": "Don't want to get these notifications?",
"emails.survey_response_finished_email_hey": "Hey 👋",
"emails.survey_response_finished_email_turn_off_notifications_for_all_new_forms":
"Turn off notifications for all newly created forms",
"emails.survey_response_finished_email_turn_off_notifications_for_this_form":
"Turn off notifications for this form",
"emails.survey_response_finished_email_view_more_responses": "View {responseCount} more responses",
"emails.survey_response_finished_email_view_survey_summary": "View survey summary",
"emails.text_variable": "Text variable",
"emails.verification_email_click_on_this_link": "You can also click on this link:",
"emails.verification_email_heading": "Almost there!",
"emails.verification_email_hey": "Hey 👋",
"emails.verification_email_if_expired_request_new_token":
"If it has expired please request a new token here:",
"emails.verification_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
"emails.verification_email_request_new_verification": "Request new verification",
"emails.verification_email_subject": "Please verify your email to use Formbricks",
"emails.verification_email_survey_name": "Survey name",
"emails.verification_email_take_survey": "Take survey",
"emails.verification_email_text": "To start using Formbricks please verify your email below:",
"emails.verification_email_thanks": "Thanks for validating your email!",
"emails.verification_email_to_fill_survey": "To fill out the survey please click on the button below:",
"emails.verification_email_verify_email": "Verify email",
"emails.verification_new_email_subject": "Email change verification",
"emails.verification_security_notice":
"If you did not request this email change, please ignore this email or contact support immediately.",
"emails.verified_link_survey_email_subject": "Your survey is ready to be filled out.",
};
// Simple string replacement for placeholders like {userName}, {surveyName}, etc.
const replacePlaceholders = (text: string, replacements?: Record<string, string>): string => {
if (!replacements) return text;
let result = text;
Object.entries(replacements).forEach(([key, value]) => {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), value);
});
return result;
};
/**
* Mock translation function for preview server
* @param key - Translation key (e.g., "emails.forgot_password_email_heading")
* @param replacements - Optional object with placeholder replacements
* @returns Translated string with placeholders replaced
*/
export const t = (key: string, replacements?: Record<string, string>): string => {
const translation = translations[key] || key;
return replacePlaceholders(translation, replacements);
};

View File

@@ -0,0 +1,116 @@
import { render } from "@react-email/render";
import { ForgotPasswordEmail } from "../../emails/auth/forgot-password-email";
import { NewEmailVerification } from "../../emails/auth/new-email-verification";
import { PasswordResetNotifyEmail } from "../../emails/auth/password-reset-notify-email";
import { VerificationEmail } from "../../emails/auth/verification-email";
import { EmailCustomizationPreviewEmail } from "../../emails/general/email-customization-preview-email";
import { InviteAcceptedEmail } from "../../emails/invite/invite-accepted-email";
import { InviteEmail } from "../../emails/invite/invite-email";
import { EmbedSurveyPreviewEmail } from "../../emails/survey/embed-survey-preview-email";
import { FollowUpEmail, FollowUpEmailProps } from "../../emails/survey/follow-up-email";
import { LinkSurveyEmail } from "../../emails/survey/link-survey-email";
import {
ResponseFinishedEmail,
ResponseFinishedEmailProps,
} from "../../emails/survey/response-finished-email";
import { TEmailTemplateLegalProps } from "../types/email";
import { TFunction } from "../types/translations";
export async function renderVerificationEmail(
props: {
verifyLink: string;
verificationRequestLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(VerificationEmail(props));
}
export async function renderForgotPasswordEmail(
props: {
verifyLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(ForgotPasswordEmail(props));
}
export async function renderNewEmailVerification(
props: {
verifyLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(NewEmailVerification(props));
}
export async function renderPasswordResetNotifyEmail(
props: { t: TFunction } & TEmailTemplateLegalProps
): Promise<string> {
return await render(PasswordResetNotifyEmail(props));
}
export async function renderInviteEmail(
props: {
inviteeName: string;
inviterName: string;
verifyLink: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(InviteEmail(props));
}
export async function renderInviteAcceptedEmail(
props: {
inviterName: string;
inviteeName: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(InviteAcceptedEmail(props));
}
export async function renderLinkSurveyEmail(
props: {
surveyName: string;
surveyLink: string;
logoUrl: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(LinkSurveyEmail(props));
}
export async function renderEmbedSurveyPreviewEmail(
props: {
html: string;
environmentId: string;
logoUrl?: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(EmbedSurveyPreviewEmail(props));
}
export async function renderResponseFinishedEmail(
props: ResponseFinishedEmailProps & TEmailTemplateLegalProps
): Promise<string> {
return await render(ResponseFinishedEmail(props));
}
export async function renderEmailCustomizationPreviewEmail(
props: {
userName: string;
logoUrl?: string;
t: TFunction;
} & TEmailTemplateLegalProps
): Promise<string> {
return await render(EmailCustomizationPreviewEmail(props));
}
export async function renderFollowUpEmail(
props: FollowUpEmailProps & TEmailTemplateLegalProps
): Promise<string> {
return await render(FollowUpEmail(props));
}

View File

@@ -0,0 +1,6 @@
export interface TEmailTemplateLegalProps {
privacyUrl?: string;
termsUrl?: string;
imprintUrl?: string;
imprintAddress?: string;
}

View File

@@ -0,0 +1,19 @@
import { TSurveyElementTypeEnum } from "@formbricks/types/surveys/elements";
export interface ProcessedResponseElement {
element: string;
response: string | string[];
type: TSurveyElementTypeEnum;
}
export interface ProcessedVariable {
id: string;
name: string;
type: "text" | "number";
value: string | number;
}
export interface ProcessedHiddenField {
id: string;
value: string;
}

Some files were not shown because too many files have changed in this diff Show More