mirror of
https://github.com/formbricks/formbricks.git
synced 2025-12-23 06:30:51 -06:00
Compare commits
25 Commits
release/4.
...
feat/datab
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e39cdd7a2 | ||
|
|
1859091efd | ||
|
|
7354122447 | ||
|
|
d54dca2b27 | ||
|
|
acd5cff534 | ||
|
|
b9b052c102 | ||
|
|
834929e766 | ||
|
|
9dd604524f | ||
|
|
5cc2a61089 | ||
|
|
520dc1f7b6 | ||
|
|
09f40ad816 | ||
|
|
689b6491b3 | ||
|
|
b70b2eef95 | ||
|
|
392a95834b | ||
|
|
6ca21bca5e | ||
|
|
66d9cc8eac | ||
|
|
befdc078f1 | ||
|
|
13b983b3b2 | ||
|
|
1e285ebe4e | ||
|
|
a7c4971952 | ||
|
|
c8689d91d5 | ||
|
|
73a2ff7421 | ||
|
|
0c28e89b41 | ||
|
|
a736436e29 | ||
|
|
7dbb0300d3 |
31
.github/workflows/chromatic.yml
vendored
31
.github/workflows/chromatic.yml
vendored
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
<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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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={"/"}>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -1910,9 +1910,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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -2048,7 +2048,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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -474,7 +474,7 @@
|
||||
"forgot_password_email_subject": "Réinitialise ton mot de passe Formbricks",
|
||||
"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",
|
||||
"imprint": "Empreinte",
|
||||
"invite_accepted_email_heading": "Salut",
|
||||
"invite_accepted_email_subject": "Vous avez un nouveau membre dans votre organisation !",
|
||||
"invite_accepted_email_text_par1": "Je te fais savoir que",
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -474,7 +474,7 @@
|
||||
"forgot_password_email_subject": "Redefinir sua senha Formbricks",
|
||||
"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",
|
||||
"imprint": "impressão",
|
||||
"invite_accepted_email_heading": "E aí",
|
||||
"invite_accepted_email_subject": "Você tem um novo membro na sua organização!",
|
||||
"invite_accepted_email_text_par1": "Só pra te avisar que",
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -2046,9 +2046,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 på 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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -2046,9 +2046,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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const managementServer = [
|
||||
{
|
||||
url: `https://app.formbricks.com/api/v2/management`,
|
||||
description: "Formbricks Management API",
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
export const organizationServer = [
|
||||
{
|
||||
url: `https://app.formbricks.com/api/v2/organizations`,
|
||||
description: "Formbricks Organizations API",
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { Column, Hr, Row, Text } from "@react-email/components";
|
||||
import React from "react";
|
||||
import sanitizeHtml from "sanitize-html";
|
||||
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: sanitizeHtml(body, {
|
||||
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
|
||||
allowedAttributes: {
|
||||
a: ["href", "rel", "target"],
|
||||
"*": ["dir", "class"],
|
||||
},
|
||||
allowedSchemes: ["http", "https"],
|
||||
allowedSchemesByTag: {
|
||||
a: ["http", "https"],
|
||||
},
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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,7 +71,6 @@
|
||||
"@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",
|
||||
"@t3-oss/env-nextjs": "0.13.4",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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**
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
198
docs/xm-and-surveys/xm/best-practices/research-panel.mdx
Normal file
198
docs/xm-and-surveys/xm/best-practices/research-panel.mdx
Normal 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
|
||||
|
||||

|
||||
|
||||
|
||||
</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
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
3. Define filter conditions based on the attributes you collected:
|
||||
- Example: `industry` equals "Technology" AND `companySize` equals "50-200"
|
||||
|
||||

|
||||
|
||||
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**
|
||||
|
||||

|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
### 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 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)
|
||||
@@ -18,6 +18,8 @@
|
||||
"db:migrate:deploy": "turbo run db:migrate:deploy",
|
||||
"db:start": "turbo run db:start",
|
||||
"db:push": "turbo run db:push",
|
||||
"db:seed": "turbo run db:seed",
|
||||
"db:seed:clear": "turbo run db:seed -- -- --clear",
|
||||
"db:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||
"db:down": "docker compose -f docker-compose.dev.yml down",
|
||||
"go": "pnpm db:up && turbo run go --concurrency 20",
|
||||
|
||||
@@ -82,6 +82,12 @@ Run these commands from the root directory of the Formbricks monorepo:
|
||||
- Generates new `migration.sql` in the custom directory
|
||||
- Copies migration to Prisma's internal directory
|
||||
- Applies all pending migrations to the database
|
||||
- **`pnpm db:seed`**: Seed the database with sample data
|
||||
- Upserts base infrastructure (Organization, Project, Environments)
|
||||
- Creates multi-role users (Admin, Manager)
|
||||
- Generates complex surveys and sample responses
|
||||
- **`pnpm db:seed:clear`**: Clear all seeded data and re-seed
|
||||
- **WARNING**: This will delete existing data in the database.
|
||||
|
||||
### Package Level Commands
|
||||
|
||||
@@ -92,6 +98,8 @@ Run these commands from the `packages/database` directory:
|
||||
- Creates new subdirectory with appropriate timestamp
|
||||
- Generates `migration.ts` file with pre-configured ID and name
|
||||
- **Note**: Only use Prisma raw queries in data migrations for better performance and to avoid type errors
|
||||
- **`pnpm db:seed`**: Run the seeding script
|
||||
- **`pnpm db:seed:clear`**: Clear data and run the seeding script
|
||||
|
||||
### Available Scripts
|
||||
|
||||
@@ -102,13 +110,41 @@ Run these commands from the `packages/database` directory:
|
||||
"db:migrate:deploy": "Apply migrations in production",
|
||||
"db:migrate:dev": "Apply migrations in development",
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
|
||||
"db:seed": "Seed the database with sample data",
|
||||
"db:seed:clear": "Clear all data and re-seed",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
|
||||
"dev": "vite build --watch",
|
||||
"generate": "prisma generate",
|
||||
"generate-data-migration": "Create new data migration"
|
||||
}
|
||||
```
|
||||
|
||||
## Database Seeding
|
||||
|
||||
The seeding system provides a quick way to set up a functional environment for development, QA, and testing.
|
||||
|
||||
### Safety Guard
|
||||
|
||||
To prevent accidental data loss in production, seeding is blocked if `NODE_ENV=production`. If you explicitly need to seed a production-like environment (e.g., staging), you must set:
|
||||
|
||||
```bash
|
||||
ALLOW_SEED=true
|
||||
```
|
||||
|
||||
### Seeding Logic
|
||||
|
||||
The `pnpm db:seed` script:
|
||||
1. **Infrastructure**: Upserts a default organization, project, and environments.
|
||||
2. **Users**: Creates default users with the following credentials (passwords are hashed):
|
||||
- **Admin**: `admin@formbricks.com` / `password123`
|
||||
- **Manager**: `manager@formbricks.com` / `password123`
|
||||
3. **Surveys**: Creates complex sample surveys (Kitchen Sink, CSAT, Draft, etc.) in the **Production** environment.
|
||||
4. **Responses**: Generates ~50 realistic responses and displays for each survey.
|
||||
|
||||
### Idempotency
|
||||
|
||||
By default, the seed script uses `upsert` to ensure it can be run multiple times without creating duplicate infrastructure. To perform a clean reset, use `pnpm db:seed:clear`.
|
||||
|
||||
## Migration Workflow
|
||||
|
||||
### Adding a Schema Migration
|
||||
|
||||
@@ -22,6 +22,9 @@
|
||||
},
|
||||
"./zod/*": {
|
||||
"import": "./zod/*.ts"
|
||||
},
|
||||
"./seed/constants": {
|
||||
"import": "./src/seed/constants.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -33,7 +36,9 @@
|
||||
"db:create-saml-database:deploy": "env SAML_DATABASE_URL=\"${SAML_DATABASE_URL}\" node ./dist/scripts/create-saml-database.js",
|
||||
"db:create-saml-database:dev": "dotenv -e ../../.env -- node ./dist/scripts/create-saml-database.js",
|
||||
"db:push": "prisma db push --accept-data-loss",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev",
|
||||
"db:seed": "dotenv -e ../../.env -- tsx src/seed.ts",
|
||||
"db:seed:clear": "dotenv -e ../../.env -- tsx src/seed.ts --clear",
|
||||
"db:setup": "pnpm db:migrate:dev && pnpm db:create-saml-database:dev && pnpm db:seed",
|
||||
"db:start": "pnpm db:setup",
|
||||
"format": "prisma format",
|
||||
"generate": "prisma generate",
|
||||
@@ -45,17 +50,20 @@
|
||||
"@formbricks/logger": "workspace:*",
|
||||
"@paralleldrive/cuid2": "2.2.2",
|
||||
"@prisma/client": "6.14.0",
|
||||
"bcryptjs": "2.4.3",
|
||||
"zod": "3.24.4",
|
||||
"zod-openapi": "4.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@formbricks/config-typescript": "workspace:*",
|
||||
"@formbricks/eslint-config": "workspace:*",
|
||||
"@types/bcryptjs": "2.4.6",
|
||||
"dotenv-cli": "8.0.0",
|
||||
"glob": "11.1.0",
|
||||
"prisma": "6.14.0",
|
||||
"prisma-json-types-generator": "3.5.4",
|
||||
"ts-node": "10.9.2",
|
||||
"tsx": "4.19.2",
|
||||
"vite": "6.4.1",
|
||||
"vite-plugin-dts": "4.5.3"
|
||||
}
|
||||
|
||||
596
packages/database/src/seed.ts
Normal file
596
packages/database/src/seed.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
import { createId } from "@paralleldrive/cuid2";
|
||||
import { type Prisma, PrismaClient } from "@prisma/client";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { SEED_CREDENTIALS, SEED_IDS } from "./seed/constants";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const isProduction = process.env.NODE_ENV === "production";
|
||||
const allowSeed = process.env.ALLOW_SEED === "true";
|
||||
|
||||
if (isProduction && !allowSeed) {
|
||||
logger.error("ERROR: Seeding blocked in production. Set ALLOW_SEED=true to override.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const clearData = process.argv.includes("--clear");
|
||||
|
||||
// Define local types to avoid resolution issues in seed script
|
||||
type SurveyElementType =
|
||||
| "openText"
|
||||
| "multipleChoiceSingle"
|
||||
| "multipleChoiceMulti"
|
||||
| "nps"
|
||||
| "cta"
|
||||
| "rating"
|
||||
| "consent"
|
||||
| "date"
|
||||
| "matrix"
|
||||
| "address"
|
||||
| "ranking"
|
||||
| "contactInfo";
|
||||
|
||||
interface SurveyQuestion {
|
||||
id: string;
|
||||
type: SurveyElementType;
|
||||
headline: { default: string; [key: string]: string };
|
||||
subheader?: { default: string; [key: string]: string };
|
||||
required?: boolean;
|
||||
placeholder?: { default: string; [key: string]: string };
|
||||
longAnswer?: boolean;
|
||||
choices?: { id: string; label: { default: string }; imageUrl?: string }[];
|
||||
lowerLabel?: { default: string };
|
||||
upperLabel?: { default: string };
|
||||
buttonLabel?: { default: string };
|
||||
buttonUrl?: string;
|
||||
buttonExternal?: boolean;
|
||||
dismissButtonLabel?: { default: string };
|
||||
ctaButtonLabel?: { default: string };
|
||||
scale?: string;
|
||||
range?: number;
|
||||
label?: { default: string };
|
||||
allowMulti?: boolean;
|
||||
format?: string;
|
||||
rows?: { id: string; label: { default: string } }[];
|
||||
columns?: { id: string; label: { default: string } }[];
|
||||
addressLine1?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
addressLine2?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
city?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
state?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
zip?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
country?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
firstName?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
lastName?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
email?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
phone?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
company?: { show: boolean; required: boolean; placeholder: { default: string } };
|
||||
allowMultipleFiles?: boolean;
|
||||
maxSizeInMB?: number;
|
||||
}
|
||||
|
||||
async function deleteData(): Promise<void> {
|
||||
logger.info("Clearing existing data...");
|
||||
|
||||
const deleteOrder: Prisma.TypeMap["meta"]["modelProps"][] = [
|
||||
"responseQuotaLink",
|
||||
"surveyQuota",
|
||||
"tagsOnResponses",
|
||||
"tag",
|
||||
"surveyFollowUp",
|
||||
"response",
|
||||
"display",
|
||||
"surveyTrigger",
|
||||
"surveyAttributeFilter",
|
||||
"surveyLanguage",
|
||||
"survey",
|
||||
"actionClass",
|
||||
"contactAttribute",
|
||||
"contactAttributeKey",
|
||||
"contact",
|
||||
"apiKeyEnvironment",
|
||||
"apiKey",
|
||||
"segment",
|
||||
"webhook",
|
||||
"integration",
|
||||
"projectTeam",
|
||||
"teamUser",
|
||||
"team",
|
||||
"project",
|
||||
"invite",
|
||||
"membership",
|
||||
"account",
|
||||
"user",
|
||||
"organization",
|
||||
];
|
||||
|
||||
for (const model of deleteOrder) {
|
||||
try {
|
||||
// @ts-expect-error - prisma[model] is not typed correctly
|
||||
await prisma[model].deleteMany();
|
||||
} catch (e: unknown) {
|
||||
const errorMessage = e instanceof Error ? e.message : String(e);
|
||||
logger.error(`Could not delete data from ${model}: ${errorMessage}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info("Data cleared.");
|
||||
}
|
||||
|
||||
const KITCHEN_SINK_QUESTIONS: SurveyQuestion[] = [
|
||||
{
|
||||
id: createId(),
|
||||
type: "openText",
|
||||
headline: { default: "What do you think of Formbricks?" },
|
||||
subheader: { default: "Please be honest!" },
|
||||
required: true,
|
||||
placeholder: { default: "Your feedback here..." },
|
||||
longAnswer: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "multipleChoiceSingle",
|
||||
headline: { default: "How often do you use Formbricks?" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Daily" } },
|
||||
{ id: createId(), label: { default: "Weekly" } },
|
||||
{ id: createId(), label: { default: "Monthly" } },
|
||||
{ id: createId(), label: { default: "Rarely" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "multipleChoiceMulti",
|
||||
headline: { default: "Which features do you use?" },
|
||||
required: false,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Surveys" } },
|
||||
{ id: createId(), label: { default: "Analytics" } },
|
||||
{ id: createId(), label: { default: "Integrations" } },
|
||||
{ id: createId(), label: { default: "Action Tracking" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "nps",
|
||||
headline: { default: "How likely are you to recommend Formbricks?" },
|
||||
required: true,
|
||||
lowerLabel: { default: "Not likely" },
|
||||
upperLabel: { default: "Very likely" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "cta",
|
||||
headline: { default: "Check out our documentation!" },
|
||||
required: true,
|
||||
ctaButtonLabel: { default: "Go to Docs" },
|
||||
buttonUrl: "https://formbricks.com/docs",
|
||||
buttonExternal: true,
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "rating",
|
||||
headline: { default: "Rate your overall experience" },
|
||||
required: true,
|
||||
scale: "star",
|
||||
range: 5,
|
||||
lowerLabel: { default: "Poor" },
|
||||
upperLabel: { default: "Excellent" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "consent",
|
||||
headline: { default: "Do you agree to our terms?" },
|
||||
required: true,
|
||||
label: { default: "I agree to the terms and conditions" },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "date",
|
||||
headline: { default: "When did you start using Formbricks?" },
|
||||
required: true,
|
||||
format: "M-d-y",
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "matrix",
|
||||
headline: { default: "How do you feel about these aspects?" },
|
||||
required: true,
|
||||
rows: [
|
||||
{ id: createId(), label: { default: "UI Design" } },
|
||||
{ id: createId(), label: { default: "Performance" } },
|
||||
{ id: createId(), label: { default: "Documentation" } },
|
||||
],
|
||||
columns: [
|
||||
{ id: createId(), label: { default: "Disappointed" } },
|
||||
{ id: createId(), label: { default: "Neutral" } },
|
||||
{ id: createId(), label: { default: "Satisfied" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "address",
|
||||
headline: { default: "Where are you located?" },
|
||||
required: true,
|
||||
addressLine1: { show: true, required: true, placeholder: { default: "Address Line 1" } },
|
||||
addressLine2: { show: true, required: false, placeholder: { default: "Address Line 2" } },
|
||||
city: { show: true, required: true, placeholder: { default: "City" } },
|
||||
state: { show: true, required: true, placeholder: { default: "State" } },
|
||||
zip: { show: true, required: true, placeholder: { default: "Zip" } },
|
||||
country: { show: true, required: true, placeholder: { default: "Country" } },
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "ranking",
|
||||
headline: { default: "Rank these features" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Feature A" } },
|
||||
{ id: createId(), label: { default: "Feature B" } },
|
||||
{ id: createId(), label: { default: "Feature C" } },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: createId(),
|
||||
type: "contactInfo",
|
||||
headline: { default: "How can we reach you?" },
|
||||
required: true,
|
||||
firstName: { show: true, required: true, placeholder: { default: "First Name" } },
|
||||
lastName: { show: true, required: true, placeholder: { default: "Last Name" } },
|
||||
email: { show: true, required: true, placeholder: { default: "Email" } },
|
||||
phone: { show: true, required: false, placeholder: { default: "Phone" } },
|
||||
company: { show: true, required: false, placeholder: { default: "Company" } },
|
||||
},
|
||||
];
|
||||
|
||||
interface SurveyBlock {
|
||||
id: string;
|
||||
name: string;
|
||||
elements: SurveyQuestion[];
|
||||
}
|
||||
|
||||
type ResponseValue = string | number | string[] | Record<string, string>;
|
||||
|
||||
const generateQuestionResponse = (q: SurveyQuestion, index: number): ResponseValue | undefined => {
|
||||
const responseGenerators: Record<SurveyElementType, () => ResponseValue | undefined> = {
|
||||
openText: () => `Sample response ${String(index)}`,
|
||||
multipleChoiceSingle: () =>
|
||||
q.choices ? q.choices[Math.floor(Math.random() * q.choices.length)].label.default : undefined,
|
||||
multipleChoiceMulti: () =>
|
||||
q.choices ? [q.choices[0].label.default, q.choices[1].label.default] : undefined,
|
||||
nps: () => Math.floor(Math.random() * 11),
|
||||
rating: () => (q.range ? Math.floor(Math.random() * q.range) + 1 : undefined),
|
||||
cta: () => "clicked",
|
||||
consent: () => "accepted",
|
||||
date: () => new Date().toISOString().split("T")[0],
|
||||
matrix: () => {
|
||||
const matrixData: Record<string, string> = {};
|
||||
if (q.rows && q.columns) {
|
||||
for (const row of q.rows) {
|
||||
matrixData[row.label.default] =
|
||||
q.columns[Math.floor(Math.random() * q.columns.length)].label.default;
|
||||
}
|
||||
}
|
||||
return matrixData;
|
||||
},
|
||||
ranking: () =>
|
||||
q.choices ? q.choices.map((c) => c.label.default).sort(() => Math.random() - 0.5) : undefined,
|
||||
address: () => ({
|
||||
addressLine1: "Main St 1",
|
||||
city: "Berlin",
|
||||
state: "Berlin",
|
||||
zip: "10115",
|
||||
country: "Germany",
|
||||
}),
|
||||
contactInfo: () => ({
|
||||
firstName: "John",
|
||||
lastName: "Doe",
|
||||
email: `john.doe.${String(index)}@example.com`,
|
||||
}),
|
||||
};
|
||||
|
||||
return responseGenerators[q.type]();
|
||||
};
|
||||
|
||||
async function generateResponses(surveyId: string, count: number): Promise<void> {
|
||||
logger.info(`Generating ${String(count)} responses for survey ${surveyId}...`);
|
||||
const survey = await prisma.survey.findUnique({
|
||||
where: { id: surveyId },
|
||||
});
|
||||
|
||||
if (!survey) return;
|
||||
|
||||
const blocks = survey.blocks as unknown as SurveyBlock[];
|
||||
const questions = blocks.flatMap((block) => block.elements);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const data: Record<string, ResponseValue> = {};
|
||||
for (const q of questions) {
|
||||
const response = generateQuestionResponse(q, i);
|
||||
if (response !== undefined) {
|
||||
data[q.id] = response;
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const display = await tx.display.create({
|
||||
data: {
|
||||
surveyId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.response.create({
|
||||
data: {
|
||||
surveyId,
|
||||
finished: true,
|
||||
// @ts-expect-error - data is not typed correctly
|
||||
data: data as unknown as Prisma.InputJsonValue,
|
||||
displayId: display.id,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Generate some displays without responses (e.g., 30% more)
|
||||
const extraDisplays = Math.floor(count * 0.3);
|
||||
logger.info(`Generating ${String(extraDisplays)} extra displays for survey ${surveyId}...`);
|
||||
|
||||
for (let i = 0; i < extraDisplays; i++) {
|
||||
await prisma.display.create({
|
||||
data: {
|
||||
surveyId,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
if (clearData) {
|
||||
await deleteData();
|
||||
}
|
||||
|
||||
logger.info("Seeding base infrastructure...");
|
||||
|
||||
// Organization
|
||||
const organization = await prisma.organization.upsert({
|
||||
where: { id: SEED_IDS.ORGANIZATION },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.ORGANIZATION,
|
||||
name: "Seed Organization",
|
||||
billing: {
|
||||
plan: "free",
|
||||
limits: { projects: 3, monthly: { responses: 1500, miu: 2000 } },
|
||||
stripeCustomerId: null,
|
||||
periodStart: new Date(),
|
||||
period: "monthly",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Users
|
||||
const passwordHash = await bcrypt.hash(SEED_CREDENTIALS.ADMIN.password, 10);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.USER_ADMIN },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.USER_ADMIN,
|
||||
name: "Admin User",
|
||||
email: SEED_CREDENTIALS.ADMIN.email,
|
||||
password: passwordHash,
|
||||
emailVerified: new Date(),
|
||||
memberships: {
|
||||
create: {
|
||||
organizationId: organization.id,
|
||||
role: "owner",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.USER_MANAGER },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.USER_MANAGER,
|
||||
name: "Manager User",
|
||||
email: SEED_CREDENTIALS.MANAGER.email,
|
||||
password: passwordHash,
|
||||
emailVerified: new Date(),
|
||||
memberships: {
|
||||
create: {
|
||||
organizationId: organization.id,
|
||||
role: "manager",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { id: SEED_IDS.USER_MEMBER },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.USER_MEMBER,
|
||||
name: "Member User",
|
||||
email: SEED_CREDENTIALS.MEMBER.email,
|
||||
password: passwordHash,
|
||||
emailVerified: new Date(),
|
||||
memberships: {
|
||||
create: {
|
||||
organizationId: organization.id,
|
||||
role: "member",
|
||||
accepted: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Project
|
||||
const project = await prisma.project.upsert({
|
||||
where: { id: SEED_IDS.PROJECT },
|
||||
update: {},
|
||||
create: {
|
||||
id: SEED_IDS.PROJECT,
|
||||
name: "Seed Project",
|
||||
organizationId: organization.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Environments
|
||||
await prisma.environment.upsert({
|
||||
where: { id: SEED_IDS.ENV_DEV },
|
||||
update: { appSetupCompleted: false },
|
||||
create: {
|
||||
id: SEED_IDS.ENV_DEV,
|
||||
type: "development",
|
||||
projectId: project.id,
|
||||
appSetupCompleted: false,
|
||||
attributeKeys: {
|
||||
create: [
|
||||
{ name: "Email", key: "email", isUnique: true, type: "default" },
|
||||
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
|
||||
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
|
||||
{ name: "userId", key: "userId", isUnique: true, type: "default" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const prodEnv = await prisma.environment.upsert({
|
||||
where: { id: SEED_IDS.ENV_PROD },
|
||||
update: { appSetupCompleted: false },
|
||||
create: {
|
||||
id: SEED_IDS.ENV_PROD,
|
||||
type: "production",
|
||||
projectId: project.id,
|
||||
appSetupCompleted: false,
|
||||
attributeKeys: {
|
||||
create: [
|
||||
{ name: "Email", key: "email", isUnique: true, type: "default" },
|
||||
{ name: "First Name", key: "firstName", isUnique: false, type: "default" },
|
||||
{ name: "Last Name", key: "lastName", isUnique: false, type: "default" },
|
||||
{ name: "userId", key: "userId", isUnique: true, type: "default" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
logger.info("Seeding surveys...");
|
||||
|
||||
const createSurveyWithBlocks = async (
|
||||
id: string,
|
||||
name: string,
|
||||
environmentId: string,
|
||||
status: "inProgress" | "draft" | "completed",
|
||||
questions: SurveyQuestion[]
|
||||
): Promise<void> => {
|
||||
const blocks = [
|
||||
{
|
||||
id: createId(),
|
||||
name: "Main Block",
|
||||
elements: questions,
|
||||
},
|
||||
];
|
||||
|
||||
await prisma.survey.upsert({
|
||||
where: { id },
|
||||
update: {
|
||||
environmentId,
|
||||
type: "link",
|
||||
// @ts-expect-error - blocks is not typed correctly
|
||||
blocks: blocks as unknown as Prisma.InputJsonValue[],
|
||||
},
|
||||
create: {
|
||||
id,
|
||||
name,
|
||||
environmentId,
|
||||
status,
|
||||
type: "link",
|
||||
// @ts-expect-error - blocks is not typed correctly
|
||||
blocks: blocks as unknown as Prisma.InputJsonValue[],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
// Kitchen Sink Survey
|
||||
await createSurveyWithBlocks(
|
||||
SEED_IDS.SURVEY_KITCHEN_SINK,
|
||||
"Kitchen Sink Survey",
|
||||
prodEnv.id,
|
||||
"inProgress",
|
||||
KITCHEN_SINK_QUESTIONS
|
||||
);
|
||||
|
||||
// CSAT Survey
|
||||
await createSurveyWithBlocks(SEED_IDS.SURVEY_CSAT, "CSAT Survey", prodEnv.id, "inProgress", [
|
||||
{
|
||||
id: createId(),
|
||||
type: "rating",
|
||||
headline: { default: "How satisfied are you with our product?" },
|
||||
required: true,
|
||||
scale: "smiley",
|
||||
range: 5,
|
||||
},
|
||||
]);
|
||||
|
||||
// Draft Survey
|
||||
await createSurveyWithBlocks(SEED_IDS.SURVEY_DRAFT, "Draft Survey", prodEnv.id, "draft", [
|
||||
{
|
||||
id: createId(),
|
||||
type: "openText",
|
||||
headline: { default: "Coming soon..." },
|
||||
required: false,
|
||||
},
|
||||
]);
|
||||
|
||||
// Completed Survey
|
||||
await createSurveyWithBlocks(SEED_IDS.SURVEY_COMPLETED, "Exit Survey", prodEnv.id, "completed", [
|
||||
{
|
||||
id: createId(),
|
||||
type: "multipleChoiceSingle",
|
||||
headline: { default: "Why are you leaving?" },
|
||||
required: true,
|
||||
choices: [
|
||||
{ id: createId(), label: { default: "Too expensive" } },
|
||||
{ id: createId(), label: { default: "Found a better alternative" } },
|
||||
{ id: createId(), label: { default: "Missing features" } },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
logger.info("Generating responses...");
|
||||
|
||||
await generateResponses(SEED_IDS.SURVEY_KITCHEN_SINK, 50);
|
||||
await generateResponses(SEED_IDS.SURVEY_CSAT, 50);
|
||||
await generateResponses(SEED_IDS.SURVEY_COMPLETED, 50);
|
||||
|
||||
logger.info(`\n${"=".repeat(50)}`);
|
||||
logger.info("🚀 SEEDING COMPLETED SUCCESSFULLY");
|
||||
logger.info("=".repeat(50));
|
||||
logger.info("\nLog in with the following credentials:");
|
||||
logger.info(`\n Admin (Owner):`);
|
||||
logger.info(` Email: ${SEED_CREDENTIALS.ADMIN.email}`);
|
||||
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
|
||||
logger.info(`\n Manager:`);
|
||||
logger.info(` Email: ${SEED_CREDENTIALS.MANAGER.email}`);
|
||||
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
|
||||
logger.info(`\n Member:`);
|
||||
logger.info(` Email: ${SEED_CREDENTIALS.MEMBER.email}`);
|
||||
logger.info(` Password: (see SEED_CREDENTIALS configuration in constants.ts)`);
|
||||
logger.info(`\n${"=".repeat(50)}\n`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e: unknown) => {
|
||||
logger.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => {
|
||||
prisma.$disconnect().catch((e: unknown) => {
|
||||
logger.error(e, "Error disconnecting prisma");
|
||||
});
|
||||
});
|
||||
19
packages/database/src/seed/constants.ts
Normal file
19
packages/database/src/seed/constants.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
export const SEED_IDS = {
|
||||
USER_ADMIN: "clseedadmin000000000000",
|
||||
USER_MANAGER: "clseedmanager0000000000",
|
||||
USER_MEMBER: "clseedmember00000000000",
|
||||
ORGANIZATION: "clseedorg0000000000000",
|
||||
PROJECT: "clseedproject000000000",
|
||||
ENV_DEV: "clseedenvdev0000000000",
|
||||
ENV_PROD: "clseedenvprod000000000",
|
||||
SURVEY_KITCHEN_SINK: "clseedsurveykitchen00",
|
||||
SURVEY_CSAT: "clseedsurveycsat000000",
|
||||
SURVEY_DRAFT: "clseedsurveydraft00000",
|
||||
SURVEY_COMPLETED: "clseedsurveycomplete00",
|
||||
} as const;
|
||||
|
||||
export const SEED_CREDENTIALS = {
|
||||
ADMIN: { email: "admin@formbricks.com", password: "Password#123" },
|
||||
MANAGER: { email: "manager@formbricks.com", password: "Password#123" },
|
||||
MEMBER: { email: "member@formbricks.com", password: "Password#123" },
|
||||
} as const;
|
||||
4
packages/email/.eslintrc.cjs
Normal file
4
packages/email/.eslintrc.cjs
Normal 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
86
packages/email/README.md
Normal 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
|
||||
36
packages/email/emails/auth/forgot-password-email.tsx
Normal file
36
packages/email/emails/auth/forgot-password-email.tsx
Normal 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} />;
|
||||
}
|
||||
40
packages/email/emails/auth/new-email-verification.tsx
Normal file
40
packages/email/emails/auth/new-email-verification.tsx
Normal 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} />;
|
||||
}
|
||||
30
packages/email/emails/auth/password-reset-notify-email.tsx
Normal file
30
packages/email/emails/auth/password-reset-notify-email.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
39
packages/email/emails/invite/invite-accepted-email.tsx
Normal file
39
packages/email/emails/invite/invite-accepted-email.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
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 })} {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 function InviteAcceptedEmailPreview(): React.JSX.Element {
|
||||
return <InviteAcceptedEmail {...exampleData.inviteAcceptedEmail} />;
|
||||
}
|
||||
43
packages/email/emails/invite/invite-email.tsx
Normal file
43
packages/email/emails/invite/invite-email.tsx
Normal 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 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 })} {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 function InviteEmailPreview(): React.JSX.Element {
|
||||
return <InviteEmail {...exampleData.inviteEmail} />;
|
||||
}
|
||||
42
packages/email/emails/survey/embed-survey-preview-email.tsx
Normal file
42
packages/email/emails/survey/embed-survey-preview-email.tsx
Normal 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} />;
|
||||
}
|
||||
86
packages/email/emails/survey/follow-up-email.tsx
Normal file
86
packages/email/emails/survey/follow-up-email.tsx
Normal 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)} />;
|
||||
}
|
||||
43
packages/email/emails/survey/link-survey-email.tsx
Normal file
43
packages/email/emails/survey/link-survey-email.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
30
packages/email/package.json
Normal file
30
packages/email/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -1,10 +1,10 @@
|
||||
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 {
|
||||
@@ -21,3 +21,5 @@ export function ElementHeader({ headline, subheader, className }: ElementHeaderP
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ElementHeader;
|
||||
@@ -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 (
|
||||
@@ -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;
|
||||
52
packages/email/src/index.ts
Normal file
52
packages/email/src/index.ts
Normal 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";
|
||||
6
packages/email/src/lib/cn.ts
Normal file
6
packages/email/src/lib/cn.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => {
|
||||
return twMerge(clsx(inputs));
|
||||
};
|
||||
@@ -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>;
|
||||
}
|
||||
};
|
||||
184
packages/email/src/lib/example-data.ts
Normal file
184
packages/email/src/lib/example-data.ts
Normal 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];
|
||||
108
packages/email/src/lib/mock-translate.ts
Normal file
108
packages/email/src/lib/mock-translate.ts
Normal 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);
|
||||
};
|
||||
116
packages/email/src/lib/render.ts
Normal file
116
packages/email/src/lib/render.ts
Normal 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));
|
||||
}
|
||||
6
packages/email/src/types/email.ts
Normal file
6
packages/email/src/types/email.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface TEmailTemplateLegalProps {
|
||||
privacyUrl?: string;
|
||||
termsUrl?: string;
|
||||
imprintUrl?: string;
|
||||
imprintAddress?: string;
|
||||
}
|
||||
19
packages/email/src/types/follow-up.ts
Normal file
19
packages/email/src/types/follow-up.ts
Normal 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;
|
||||
}
|
||||
1
packages/email/src/types/translations.ts
Normal file
1
packages/email/src/types/translations.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type TFunction = (key: string, replacements?: Record<string, string>) => string;
|
||||
5
packages/email/tailwind.config.js
Normal file
5
packages/email/tailwind.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{ts,tsx}", "./emails/**/*.{ts,tsx}"],
|
||||
plugins: [],
|
||||
};
|
||||
10
packages/email/tsconfig.json
Normal file
10
packages/email/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"outDir": "dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"exclude": ["node_modules", "dist"],
|
||||
"extends": "@formbricks/config-typescript/react-library.json",
|
||||
"include": ["src/**/*", "emails/**/*"]
|
||||
}
|
||||
@@ -25,6 +25,7 @@ const __dirname = dirname(__filename);
|
||||
|
||||
// Configuration for Web App
|
||||
const WEB_APP_DIR = path.join(__dirname, "..", "..", "..", "apps", "web");
|
||||
const EMAIL_PKG_DIR = path.join(__dirname, "..", "..", "..", "packages", "email");
|
||||
const WEB_APP_LOCALES_DIR = path.join(WEB_APP_DIR, "locales");
|
||||
const WEB_APP_DEFAULT_LOCALE = "en-US";
|
||||
|
||||
@@ -139,27 +140,32 @@ export function extractKeysFromContent(content: string): string[] {
|
||||
/**
|
||||
* Scan source files for translation keys
|
||||
*/
|
||||
async function scanSourceFiles(sourceDir: string, packageName: string): Promise<Set<string>> {
|
||||
async function scanSourceFiles(sourceDirs: string | string[], packageName: string): Promise<Set<string>> {
|
||||
console.log(`🔍 Scanning ${packageName} source files for translation keys...`);
|
||||
|
||||
const usedKeys = new Set<string>();
|
||||
const dirs = Array.isArray(sourceDirs) ? sourceDirs : [sourceDirs];
|
||||
|
||||
// Find all TypeScript and TypeScript React files
|
||||
const files = await glob("**/*.{ts,tsx}", {
|
||||
cwd: sourceDir,
|
||||
ignore: EXCLUDE_DIRS,
|
||||
absolute: true,
|
||||
});
|
||||
for (const dir of dirs) {
|
||||
// Find all TypeScript and TypeScript React files
|
||||
const files = await glob("**/*.{ts,tsx}", {
|
||||
cwd: dir,
|
||||
ignore: EXCLUDE_DIRS,
|
||||
absolute: true,
|
||||
});
|
||||
|
||||
console.log(` Found ${files.length.toString()} files to scan`);
|
||||
console.log(
|
||||
` Found ${files.length.toString()} files to scan in ${path.relative(path.join(__dirname, "..", "..", ".."), dir)}`
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(file, "utf-8");
|
||||
const keys = extractKeysFromContent(content);
|
||||
keys.forEach((key) => usedKeys.add(key));
|
||||
} catch (error) {
|
||||
console.error(`❌ Error: Could not read file ${file}:`, error);
|
||||
for (const file of files) {
|
||||
try {
|
||||
const content = await fs.promises.readFile(file, "utf-8");
|
||||
const keys = extractKeysFromContent(content);
|
||||
keys.forEach((key) => usedKeys.add(key));
|
||||
} catch (error) {
|
||||
console.error(`❌ Error: Could not read file ${file}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,13 +429,13 @@ function displayResults(results: ScanResults, packageName: string, defaultLocale
|
||||
* Validate translations for a single package
|
||||
*/
|
||||
async function validatePackage(
|
||||
sourceDir: string,
|
||||
sourceDirs: string | string[],
|
||||
localesDir: string,
|
||||
defaultLocale: string,
|
||||
packageName: string
|
||||
): Promise<ScanResults> {
|
||||
// Scan source files for used keys
|
||||
const usedKeys = await scanSourceFiles(sourceDir, packageName);
|
||||
const usedKeys = await scanSourceFiles(sourceDirs, packageName);
|
||||
|
||||
// Load translation keys from all locale files
|
||||
const translationsByLocale = await loadAllTranslationKeys(localesDir, defaultLocale, packageName);
|
||||
@@ -461,7 +467,7 @@ async function main(): Promise<void> {
|
||||
try {
|
||||
// Validate Web App
|
||||
const webAppResults = await validatePackage(
|
||||
WEB_APP_DIR,
|
||||
[WEB_APP_DIR, EMAIL_PKG_DIR],
|
||||
WEB_APP_LOCALES_DIR,
|
||||
WEB_APP_DEFAULT_LOCALE,
|
||||
"Web App"
|
||||
|
||||
@@ -1,101 +1,218 @@
|
||||
# @formbricks/survey-ui
|
||||
|
||||
Reusable UI components package for Formbricks applications.
|
||||
React UI components for building surveys and forms. Includes NPS, rating scales, multi-select, file upload, and more.
|
||||
|
||||

|
||||
|
||||
## Installation
|
||||
|
||||
This package is part of the Formbricks monorepo and is available as a workspace dependency.
|
||||
```bash
|
||||
npm install @formbricks/survey-ui
|
||||
```
|
||||
|
||||
## Usage
|
||||
**Requirements:** React 19 (`react@^19.0.0`)
|
||||
|
||||
## Quick Start
|
||||
|
||||
```tsx
|
||||
import { Button } from "@formbricks/survey-ui";
|
||||
import { OpenText, Rating } from "@formbricks/survey-ui";
|
||||
import "@formbricks/survey-ui/styles";
|
||||
|
||||
function MyComponent() {
|
||||
function Survey() {
|
||||
return (
|
||||
<Button variant="default" size="default">
|
||||
Click me
|
||||
</Button>
|
||||
<div id="fbjs">
|
||||
<OpenText
|
||||
elementId="name"
|
||||
headline="What's your name?"
|
||||
inputId="name-field"
|
||||
placeholder="Enter your name"
|
||||
onChange={(value) => console.log(value)}
|
||||
/>
|
||||
|
||||
<Rating
|
||||
elementId="rating"
|
||||
headline="Rate your experience"
|
||||
inputId="rating-field"
|
||||
scale="star"
|
||||
range={5}
|
||||
onChange={(value) => console.log(value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Development
|
||||
> **Important:** Components must be wrapped in `<div id="fbjs">` for styles to work.
|
||||
|
||||
```bash
|
||||
# Build the package
|
||||
pnpm build
|
||||
## Components
|
||||
|
||||
# Watch mode for development
|
||||
pnpm dev
|
||||
### Survey Elements
|
||||
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `OpenText` | Text input (single or multi-line) |
|
||||
| `SingleSelect` | Radio button selection |
|
||||
| `MultiSelect` | Checkbox selection |
|
||||
| `Rating` | Star, number, or smiley rating |
|
||||
| `NPS` | Net Promoter Score (0-10) |
|
||||
| `Matrix` | Table/grid selection |
|
||||
| `Ranking` | Drag-and-drop ranking |
|
||||
| `DateElement` | Date picker |
|
||||
| `FileUpload` | File upload with preview |
|
||||
| `PictureSelect` | Image-based selection |
|
||||
| `Consent` | Checkbox with label |
|
||||
| `CTA` | Call-to-action button |
|
||||
| `FormField` | Generic form field wrapper |
|
||||
|
||||
# Lint
|
||||
pnpm lint
|
||||
```
|
||||
### General Components
|
||||
|
||||
## Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
├── components/ # React components
|
||||
├── lib/ # Utility functions
|
||||
└── index.ts # Main entry point
|
||||
```
|
||||
|
||||
## Adding New Components
|
||||
|
||||
### Using shadcn CLI (Recommended)
|
||||
|
||||
This package is configured to work with shadcn/ui CLI. You can add components using:
|
||||
|
||||
```bash
|
||||
cd packages/survey-ui
|
||||
pnpm ui:add <component-name>
|
||||
```
|
||||
|
||||
**Important**: After adding a component, reorganize it into a folder structure:
|
||||
|
||||
For example:
|
||||
```bash
|
||||
pnpm ui:add button
|
||||
pnpm ui:organize button
|
||||
```
|
||||
|
||||
Then export the component from `src/components/index.ts`.
|
||||
|
||||
### Manual Component Creation
|
||||
|
||||
1. Create a new component directory under `src/components/<component-name>/`
|
||||
2. Create `index.tsx` inside that directory
|
||||
3. Export the component from `src/components/index.ts`
|
||||
4. The component will be available from the main package export
|
||||
|
||||
## Component Structure
|
||||
|
||||
Components follow this folder structure:
|
||||
|
||||
```text
|
||||
src/components/
|
||||
├── button.tsx
|
||||
├── button.stories.tsx
|
||||
```
|
||||
| Component | Description |
|
||||
|-----------|-------------|
|
||||
| `Button` | Button with variants: `default`, `outline`, `ghost`, `destructive` |
|
||||
| `Input` | Text input |
|
||||
| `DropdownMenu` | Dropdown menu (Radix UI) |
|
||||
| `ElementHeader` | Question headline + description |
|
||||
| `ElementMedia` | Image/video display |
|
||||
|
||||
## Theming
|
||||
|
||||
This package uses CSS variables for theming. The theme can be customized by modifying `src/styles/globals.css`.
|
||||
Customize the appearance by overriding CSS variables inside `#fbjs`:
|
||||
|
||||
Both light and dark modes are supported out of the box.
|
||||
```css
|
||||
#fbjs {
|
||||
/* Brand color - affects focus rings, selections */
|
||||
--fb-survey-brand-color: #3b82f6;
|
||||
|
||||
## CSS Scoping
|
||||
/* Buttons */
|
||||
--fb-button-bg-color: #3b82f6;
|
||||
--fb-button-text-color: #ffffff;
|
||||
--fb-button-border-radius: 8px;
|
||||
|
||||
By default, this package builds CSS scoped to `#fbjs` for use in the surveys package. This ensures proper specificity and prevents conflicts with preflight CSS.
|
||||
/* Inputs & Options */
|
||||
--fb-input-bg-color: #ffffff;
|
||||
--fb-input-border-radius: 8px;
|
||||
--fb-input-height: 44px;
|
||||
|
||||
To build unscoped CSS (e.g., for standalone usage or Storybook), set the `SURVEY_UI_UNSCOPED` environment variable:
|
||||
|
||||
```bash
|
||||
SURVEY_UI_UNSCOPED=true pnpm build
|
||||
/* Typography */
|
||||
--fb-element-headline-font-size: 18px;
|
||||
--fb-element-headline-font-weight: 600;
|
||||
--fb-element-headline-color: #111827;
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Storybook imports the source CSS directly and compiles it with its own Tailwind config, so it's not affected by this scoping setting.
|
||||
### All CSS Variables
|
||||
|
||||
<details>
|
||||
<summary>Click to expand full variable reference</summary>
|
||||
|
||||
#### Brand & Accent
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--fb-survey-brand-color` | `#64748b` | Primary accent color |
|
||||
| `--fb-accent-background-color` | `#e2e8f0` | Accent background |
|
||||
| `--fb-accent-background-color-selected` | `#f1f5f9` | Selected accent background |
|
||||
|
||||
#### Buttons
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--fb-button-bg-color` | `#1e293b` | Button background |
|
||||
| `--fb-button-text-color` | `#f8fafc` | Button text |
|
||||
| `--fb-button-border-radius` | `10px` | Button corners |
|
||||
| `--fb-button-height` | `36px` | Button height |
|
||||
| `--fb-button-font-size` | `14px` | Button text size |
|
||||
| `--fb-button-font-weight` | `500` | Button text weight |
|
||||
| `--fb-button-padding-x` | `16px` | Horizontal padding |
|
||||
| `--fb-button-padding-y` | `8px` | Vertical padding |
|
||||
|
||||
#### Inputs
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--fb-input-bg-color` | `#f8fafc` | Input background |
|
||||
| `--fb-input-border-color` | `#64748b` | Input border (uses brand color) |
|
||||
| `--fb-input-border-radius` | `10px` | Input corners |
|
||||
| `--fb-input-height` | `40px` | Input height |
|
||||
| `--fb-input-color` | `#0a0a0a` | Input text color |
|
||||
| `--fb-input-font-size` | `14px` | Input text size |
|
||||
| `--fb-input-placeholder-opacity` | `0.5` | Placeholder opacity |
|
||||
| `--fb-input-padding-x` | `16px` | Horizontal padding |
|
||||
| `--fb-input-padding-y` | `16px` | Vertical padding |
|
||||
| `--fb-input-shadow` | `0 1px 2px rgba(0,0,0,0.05)` | Input shadow |
|
||||
|
||||
#### Options (Radio/Checkbox)
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--fb-option-bg-color` | `#f8fafc` | Option background |
|
||||
| `--fb-option-label-color` | `#0a0a0a` | Option text color |
|
||||
| `--fb-option-border-radius` | `10px` | Option corners |
|
||||
| `--fb-option-padding-x` | `16px` | Horizontal padding |
|
||||
| `--fb-option-padding-y` | `16px` | Vertical padding |
|
||||
| `--fb-option-font-size` | `14px` | Option text size |
|
||||
|
||||
#### Headlines & Descriptions
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--fb-element-headline-font-size` | `16px` | Headline size |
|
||||
| `--fb-element-headline-font-weight` | `400` | Headline weight |
|
||||
| `--fb-element-headline-color` | `#000000` | Headline color |
|
||||
| `--fb-element-description-font-size` | `14px` | Description size |
|
||||
| `--fb-element-description-color` | `#000000` | Description color |
|
||||
|
||||
#### Progress Bar
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `--fb-progress-track-height` | `8px` | Track height |
|
||||
| `--fb-progress-track-bg-color` | `rgba(30,41,59,0.2)` | Track background |
|
||||
| `--fb-progress-indicator-bg-color` | `#1e293b` | Indicator background |
|
||||
|
||||
</details>
|
||||
|
||||
### Theme Examples
|
||||
|
||||
**Blue Theme:**
|
||||
```css
|
||||
#fbjs {
|
||||
--fb-survey-brand-color: #2563eb;
|
||||
--fb-button-bg-color: #2563eb;
|
||||
--fb-button-text-color: #ffffff;
|
||||
}
|
||||
```
|
||||
|
||||
**Green Theme:**
|
||||
```css
|
||||
#fbjs {
|
||||
--fb-survey-brand-color: #16a34a;
|
||||
--fb-button-bg-color: #16a34a;
|
||||
--fb-button-text-color: #ffffff;
|
||||
}
|
||||
```
|
||||
|
||||
**Rounded Theme:**
|
||||
```css
|
||||
#fbjs {
|
||||
--fb-input-border-radius: 9999px;
|
||||
--fb-button-border-radius: 9999px;
|
||||
--fb-option-border-radius: 16px;
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript
|
||||
|
||||
All components export their prop types:
|
||||
|
||||
```tsx
|
||||
import {
|
||||
OpenText, type OpenTextProps,
|
||||
Rating, type RatingProps,
|
||||
SingleSelect, type SingleSelectProps, type SingleSelectOption
|
||||
} from "@formbricks/survey-ui";
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT — [Formbricks](https://formbricks.com)
|
||||
|
||||
BIN
packages/survey-ui/images/survey-preview.png
Normal file
BIN
packages/survey-ui/images/survey-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -1,22 +1,47 @@
|
||||
{
|
||||
"name": "@formbricks/survey-ui",
|
||||
"license": "MIT",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Reusable UI components for Formbricks applications",
|
||||
"version": "1.0.4",
|
||||
"description": "React UI components for building surveys and forms - includes NPS, rating scales, multi-select, file upload, and more. Built with Radix UI and Tailwind CSS.",
|
||||
"homepage": "https://formbricks.com",
|
||||
"type": "module",
|
||||
"keywords": [
|
||||
"react",
|
||||
"survey",
|
||||
"form",
|
||||
"ui",
|
||||
"components",
|
||||
"nps",
|
||||
"rating",
|
||||
"questionnaire",
|
||||
"feedback",
|
||||
"formbricks",
|
||||
"tailwindcss",
|
||||
"typescript",
|
||||
"accessible"
|
||||
],
|
||||
"author": {
|
||||
"name": "Formbricks",
|
||||
"url": "https://formbricks.com"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/formbricks/formbricks"
|
||||
"url": "https://github.com/formbricks/formbricks",
|
||||
"directory": "packages/survey-ui"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"bugs": {
|
||||
"url": "https://github.com/formbricks/formbricks/issues"
|
||||
},
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"source": "src/index.ts",
|
||||
"main": "dist/index.js",
|
||||
"module": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"files": [
|
||||
"dist"
|
||||
"dist",
|
||||
"README.md"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
|
||||
@@ -145,7 +145,6 @@ interface UploadAreaProps {
|
||||
onDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
onDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||
showUploader: boolean;
|
||||
uploadedFiles: UploadedFile[];
|
||||
}
|
||||
|
||||
function UploadArea({
|
||||
@@ -161,7 +160,6 @@ function UploadArea({
|
||||
onDragOver,
|
||||
onDrop,
|
||||
showUploader,
|
||||
uploadedFiles,
|
||||
}: Readonly<UploadAreaProps>): React.JSX.Element | null {
|
||||
if (!showUploader) {
|
||||
return null;
|
||||
@@ -203,7 +201,7 @@ function UploadArea({
|
||||
accept={acceptAttribute}
|
||||
onChange={onFileChange}
|
||||
disabled={disabled}
|
||||
required={uploadedFiles.length > 0 ? false : required}
|
||||
required={required}
|
||||
dir={dir}
|
||||
aria-label="File upload"
|
||||
aria-describedby={`${inputId}-label`}
|
||||
@@ -325,7 +323,6 @@ function FileUpload({
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
showUploader={showUploader}
|
||||
uploadedFiles={uploadedFiles}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -117,6 +117,16 @@ describe("convertToEmbedUrl", () => {
|
||||
expect(result).toBe("https://player.vimeo.com/video/987654321");
|
||||
});
|
||||
|
||||
test("handles already-embedded Vimeo URLs", () => {
|
||||
const result = convertToEmbedUrl("https://player.vimeo.com/video/123456789");
|
||||
expect(result).toBe("https://player.vimeo.com/video/123456789");
|
||||
});
|
||||
|
||||
test("handles Vimeo URLs with query parameters", () => {
|
||||
const result = convertToEmbedUrl("https://vimeo.com/123456789?some=param");
|
||||
expect(result).toBe("https://player.vimeo.com/video/123456789");
|
||||
});
|
||||
|
||||
test("returns undefined for invalid Vimeo URLs", () => {
|
||||
const result = convertToEmbedUrl("https://www.vimeo.com/invalid");
|
||||
expect(result).toBeUndefined();
|
||||
@@ -134,6 +144,16 @@ describe("convertToEmbedUrl", () => {
|
||||
expect(result).toBe("https://www.loom.com/embed/xyz789");
|
||||
});
|
||||
|
||||
test("handles already-embedded Loom URLs", () => {
|
||||
const result = convertToEmbedUrl("https://www.loom.com/embed/abc123def456");
|
||||
expect(result).toBe("https://www.loom.com/embed/abc123def456");
|
||||
});
|
||||
|
||||
test("handles Loom URLs with query parameters", () => {
|
||||
const result = convertToEmbedUrl("https://www.loom.com/share/abc123def456?some=param");
|
||||
expect(result).toBe("https://www.loom.com/embed/abc123def456");
|
||||
});
|
||||
|
||||
test("returns undefined for invalid Loom URLs", () => {
|
||||
const result = convertToEmbedUrl("https://www.loom.com/invalid");
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
@@ -47,7 +47,10 @@ export default defineConfig({
|
||||
},
|
||||
plugins: [
|
||||
tsconfigPaths(),
|
||||
dts({ include: ["src"] }),
|
||||
dts({
|
||||
include: ["src"],
|
||||
exclude: ["**/*.stories.tsx", "**/*.test.ts", "**/story-helpers.ts"],
|
||||
}),
|
||||
tailwindcss(),
|
||||
],
|
||||
test: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user