mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-11 11:09:01 -05:00
Compare commits
78 Commits
fix-update
...
fix-plural
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d297a3acd | ||
|
|
abbf634c48 | ||
|
|
8a01425f39 | ||
|
|
2ab0441404 | ||
|
|
299ae81b21 | ||
|
|
f73f13f16c | ||
|
|
e9bcbf6e4c | ||
|
|
32eda35a71 | ||
|
|
84999cddfd | ||
|
|
f0a0cf531a | ||
|
|
f3e02fa466 | ||
|
|
f0a93ae092 | ||
|
|
1c922dfe2c | ||
|
|
33010fb6f5 | ||
|
|
d5fdacadd7 | ||
|
|
d939263472 | ||
|
|
e4aa66b067 | ||
|
|
ffcc101ed9 | ||
|
|
2740cd16b9 | ||
|
|
7eb94f0bd5 | ||
|
|
6dd2e707fe | ||
|
|
58d5de7d45 | ||
|
|
7c3fa8b5ea | ||
|
|
2601169877 | ||
|
|
aecf85815a | ||
|
|
c6ebaea989 | ||
|
|
68c1422733 | ||
|
|
6942502baf | ||
|
|
a4bd217761 | ||
|
|
fee770358c | ||
|
|
44f8f80cac | ||
|
|
858a7f7aa9 | ||
|
|
ac40b90e81 | ||
|
|
aa21b4e442 | ||
|
|
fa72296de5 | ||
|
|
3776b31794 | ||
|
|
5c7ea33fb0 | ||
|
|
70b8b13e66 | ||
|
|
33f60ce2be | ||
|
|
c0386cea5a | ||
|
|
7cea53130c | ||
|
|
0636989d67 | ||
|
|
262edd4602 | ||
|
|
7a752b073d | ||
|
|
ad785453ca | ||
|
|
219883266c | ||
|
|
e1d3ba3e69 | ||
|
|
e1b30d2ade | ||
|
|
55fc2b2bc8 | ||
|
|
6e4ef9a099 | ||
|
|
ebf7d1e3a1 | ||
|
|
998162bc48 | ||
|
|
4fadc54b4e | ||
|
|
f4ac9a8292 | ||
|
|
7c8a7606b7 | ||
|
|
225217330b | ||
|
|
589c04a530 | ||
|
|
aa538a3a51 | ||
|
|
817e108ff5 | ||
|
|
33542d0c54 | ||
|
|
f37d22f13d | ||
|
|
202ae903ac | ||
|
|
6ab5cc367c | ||
|
|
21559045ba | ||
|
|
d7c57a7a48 | ||
|
|
11b2ef4788 | ||
|
|
6fefd51cce | ||
|
|
65af826222 | ||
|
|
12eb54c653 | ||
|
|
5aa1427e64 | ||
|
|
08ac490512 | ||
|
|
4538c7bbcb | ||
|
|
7495c04048 | ||
|
|
85a1318f77 | ||
|
|
22ae0a731e | ||
|
|
f7e8bc1630 | ||
|
|
36f091bc73 | ||
|
|
091b78d1e3 |
6
.github/workflows/release-helm-chart.yml
vendored
6
.github/workflows/release-helm-chart.yml
vendored
@@ -65,8 +65,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
echo "Updating Chart.yaml with version: ${VERSION}"
|
||||
yq -i ".version = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||
yq -i ".appVersion = \"${VERSION}\"" helm-chart/Chart.yaml
|
||||
yq -i ".version = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
yq -i ".appVersion = \"${VERSION}\"" charts/formbricks/Chart.yaml
|
||||
|
||||
echo "✅ Successfully updated Chart.yaml"
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
echo "Packaging Helm chart version: ${VERSION}"
|
||||
helm package ./helm-chart
|
||||
helm package ./charts/formbricks
|
||||
|
||||
echo "✅ Successfully packaged formbricks-${VERSION}.tgz"
|
||||
|
||||
|
||||
4
.github/workflows/sonarqube.yml
vendored
4
.github/workflows/sonarqube.yml
vendored
@@ -9,6 +9,7 @@ on:
|
||||
merge_group:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
jobs:
|
||||
sonarqube:
|
||||
name: SonarQube
|
||||
@@ -50,6 +51,9 @@ jobs:
|
||||
pnpm test:coverage
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@2500896589ef8f7247069a56136f8dc177c27ccf
|
||||
with:
|
||||
args: >
|
||||
-Dsonar.verbose=true
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
|
||||
42
.github/workflows/translation-check.yml
vendored
42
.github/workflows/translation-check.yml
vendored
@@ -6,19 +6,9 @@ permissions:
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
paths:
|
||||
- "apps/web/**/*.ts"
|
||||
- "apps/web/**/*.tsx"
|
||||
- "apps/web/locales/**/*.json"
|
||||
- "scan-translations.ts"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "apps/web/**/*.ts"
|
||||
- "apps/web/**/*.tsx"
|
||||
- "apps/web/locales/**/*.json"
|
||||
- "scan-translations.ts"
|
||||
|
||||
jobs:
|
||||
validate-translations:
|
||||
@@ -33,30 +23,38 @@ jobs:
|
||||
egress-policy: audit
|
||||
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
|
||||
- name: Check for relevant changes
|
||||
id: changes
|
||||
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
|
||||
with:
|
||||
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
|
||||
filters: |
|
||||
translations:
|
||||
- 'apps/web/**/*.ts'
|
||||
- 'apps/web/**/*.tsx'
|
||||
- 'apps/web/locales/**/*.json'
|
||||
- 'packages/surveys/src/**/*.{ts,tsx}'
|
||||
- 'packages/surveys/locales/**/*.json'
|
||||
- 'packages/email/**/*.{ts,tsx}'
|
||||
|
||||
- name: Setup Node.js 22.x
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af
|
||||
with:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Install pnpm
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4.1.0
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm install --config.platform=linux --config.architecture=x64
|
||||
|
||||
- name: Validate translation keys
|
||||
run: |
|
||||
echo ""
|
||||
echo "🔍 Validating translation keys..."
|
||||
echo ""
|
||||
pnpm run scan-translations
|
||||
if: steps.changes.outputs.translations == 'true'
|
||||
run: pnpm run scan-translations
|
||||
|
||||
- name: Summary
|
||||
if: success()
|
||||
run: |
|
||||
echo ""
|
||||
echo "✅ Translation validation completed successfully!"
|
||||
echo ""
|
||||
- name: Skip (no translation-related changes)
|
||||
if: steps.changes.outputs.translations != 'true'
|
||||
run: echo "No translation-related files changed — skipping validation."
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
echo "{\"branchName\": \"$(git rev-parse --abbrev-ref HEAD)\"}" > ./branch.json
|
||||
prettier --write ./branch.json
|
||||
@@ -1,40 +1 @@
|
||||
# Load environment variables from .env files
|
||||
if [ -f .env ]; then
|
||||
set -a
|
||||
. .env
|
||||
set +a
|
||||
fi
|
||||
|
||||
pnpm lint-staged
|
||||
|
||||
# Run Lingo.dev i18n workflow if LINGODOTDEV_API_KEY is set
|
||||
if [ -n "$LINGODOTDEV_API_KEY" ]; then
|
||||
echo ""
|
||||
echo "🌍 Running Lingo.dev translation workflow..."
|
||||
echo ""
|
||||
|
||||
# Run translation generation and validation
|
||||
if pnpm run i18n; then
|
||||
echo ""
|
||||
echo "✅ Translation validation passed"
|
||||
echo ""
|
||||
# Add updated locale files to git
|
||||
git add apps/web/locales/*.json
|
||||
else
|
||||
echo ""
|
||||
echo "❌ Translation validation failed!"
|
||||
echo ""
|
||||
echo "Please fix the translation issues above before committing:"
|
||||
echo " • Add missing translation keys to your locale files"
|
||||
echo " • Remove unused translation keys"
|
||||
echo ""
|
||||
echo "Or run 'pnpm i18n' to see the detailed report"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Skipping translation validation: LINGODOTDEV_API_KEY is not set"
|
||||
echo " (This is expected for community contributors)"
|
||||
echo ""
|
||||
fi
|
||||
pnpm lint-staged
|
||||
@@ -10,25 +10,20 @@
|
||||
"build-storybook": "storybook build",
|
||||
"clean": "rimraf .turbo node_modules dist storybook-static"
|
||||
},
|
||||
"dependencies": {
|
||||
"@formbricks/survey-ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@chromatic-com/storybook": "^5.0.0",
|
||||
"@storybook/addon-a11y": "10.1.11",
|
||||
"@storybook/addon-links": "10.1.11",
|
||||
"@storybook/addon-onboarding": "10.1.11",
|
||||
"@storybook/react-vite": "10.1.11",
|
||||
"@typescript-eslint/eslint-plugin": "8.53.0",
|
||||
"@tailwindcss/vite": "4.1.18",
|
||||
"@typescript-eslint/parser": "8.53.0",
|
||||
"@vitejs/plugin-react": "5.1.2",
|
||||
"esbuild": "0.25.12",
|
||||
"@chromatic-com/storybook": "^5.0.1",
|
||||
"@storybook/addon-a11y": "10.2.14",
|
||||
"@storybook/addon-links": "10.2.14",
|
||||
"@storybook/addon-onboarding": "10.2.14",
|
||||
"@storybook/react-vite": "10.2.14",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.1",
|
||||
"@tailwindcss/vite": "4.2.1",
|
||||
"@typescript-eslint/parser": "8.56.1",
|
||||
"@vitejs/plugin-react": "5.1.4",
|
||||
"eslint-plugin-react-refresh": "0.4.26",
|
||||
"eslint-plugin-storybook": "10.1.11",
|
||||
"prop-types": "15.8.1",
|
||||
"storybook": "10.1.11",
|
||||
"eslint-plugin-storybook": "10.2.14",
|
||||
"storybook": "10.2.14",
|
||||
"vite": "7.3.1",
|
||||
"@storybook/addon-docs": "10.1.11"
|
||||
"@storybook/addon-docs": "10.2.14"
|
||||
}
|
||||
}
|
||||
|
||||
6
apps/web/.prettierrc.js
Normal file
6
apps/web/.prettierrc.js
Normal file
@@ -0,0 +1,6 @@
|
||||
const baseConfig = require("../../.prettierrc.js");
|
||||
|
||||
module.exports = {
|
||||
...baseConfig,
|
||||
tailwindConfig: "./tailwind.config.js",
|
||||
};
|
||||
@@ -101,6 +101,9 @@ RUN chown -R nextjs:nextjs ./apps/web/public && chmod -R 755 ./apps/web/public
|
||||
# Create packages/database directory structure with proper ownership for runtime migrations
|
||||
RUN mkdir -p ./packages/database/migrations && chown -R nextjs:nextjs ./packages/database
|
||||
|
||||
COPY --from=installer /app/packages/database/package.json ./packages/database/package.json
|
||||
RUN chown nextjs:nextjs ./packages/database/package.json && chmod 644 ./packages/database/package.json
|
||||
|
||||
COPY --from=installer /app/packages/database/schema.prisma ./packages/database/schema.prisma
|
||||
RUN chown nextjs:nextjs ./packages/database/schema.prisma && chmod 644 ./packages/database/schema.prisma
|
||||
|
||||
@@ -126,6 +129,22 @@ RUN chmod -R 755 ./node_modules/@noble/hashes
|
||||
COPY --from=installer /app/node_modules/zod ./node_modules/zod
|
||||
RUN chmod -R 755 ./node_modules/zod
|
||||
|
||||
# Pino loads transport code in worker threads via dynamic require().
|
||||
# Next.js file tracing only traces static imports, missing runtime-loaded files
|
||||
# (e.g. pino/lib/transport-stream.js, transport targets).
|
||||
# Copy the full packages to ensure all runtime files are available.
|
||||
COPY --from=installer /app/node_modules/pino ./node_modules/pino
|
||||
RUN chmod -R 755 ./node_modules/pino
|
||||
|
||||
COPY --from=installer /app/node_modules/pino-opentelemetry-transport ./node_modules/pino-opentelemetry-transport
|
||||
RUN chmod -R 755 ./node_modules/pino-opentelemetry-transport
|
||||
|
||||
COPY --from=installer /app/node_modules/pino-abstract-transport ./node_modules/pino-abstract-transport
|
||||
RUN chmod -R 755 ./node_modules/pino-abstract-transport
|
||||
|
||||
COPY --from=installer /app/node_modules/otlp-logger ./node_modules/otlp-logger
|
||||
RUN chmod -R 755 ./node_modules/otlp-logger
|
||||
|
||||
# Install prisma CLI globally for database migrations and fix permissions for nextjs user
|
||||
RUN npm install --ignore-scripts -g prisma@6 \
|
||||
&& chown -R nextjs:nextjs /usr/local/lib/node_modules/prisma
|
||||
|
||||
@@ -69,7 +69,7 @@ export const ConnectWithFormbricks = ({
|
||||
) : (
|
||||
<div className="flex animate-pulse flex-col items-center space-y-4">
|
||||
<span className="relative flex h-10 w-10">
|
||||
<span className="animate-ping-slow absolute inline-flex h-full w-full rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="absolute inline-flex h-full w-full animate-ping-slow rounded-full bg-slate-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-10 w-10 rounded-full bg-slate-500"></span>
|
||||
</span>
|
||||
<p className="pt-4 text-sm font-medium text-slate-600">
|
||||
|
||||
@@ -46,7 +46,7 @@ const Page = async (props: ConnectPageProps) => {
|
||||
channel={channel}
|
||||
/>
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}`}>
|
||||
|
||||
@@ -49,7 +49,7 @@ const Page = async (props: XMTemplatePageProps) => {
|
||||
<XMTemplateList project={project} user={user} environmentId={environment.id} />
|
||||
{projects.length >= 2 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={`/environments/${environment.id}/surveys`}>
|
||||
|
||||
@@ -42,7 +42,7 @@ export const LandingSidebar = ({ user, organization }: LandingSidebarProps) => {
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"w-sidebar-collapsed z-40 flex flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
"z-40 flex w-sidebar-collapsed flex-col justify-between rounded-r-xl border-r border-slate-200 bg-white pt-3 shadow-md transition-all duration-100"
|
||||
)}>
|
||||
<Image src={FBLogo} width={160} height={30} alt={t("environments.formbricks_logo")} />
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const Page = async (props: ChannelPageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -47,7 +47,7 @@ const Page = async (props: ModePageProps) => {
|
||||
<OnboardingOptionsContainer options={channelOptions} />
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -228,7 +228,7 @@ export const ProjectSettings = ({
|
||||
</FormProvider>
|
||||
</div>
|
||||
|
||||
<div className="relative flex h-[30rem] w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 shadow">
|
||||
<div className="relative flex w-1/2 flex-col items-center justify-center space-y-2 rounded-lg border bg-slate-200 p-6 shadow">
|
||||
{logoUrl && (
|
||||
<Image
|
||||
src={logoUrl}
|
||||
@@ -239,18 +239,16 @@ export const ProjectSettings = ({
|
||||
/>
|
||||
)}
|
||||
<p className="text-sm text-slate-400">{t("common.preview")}</p>
|
||||
<div className="z-0 h-3/4 w-3/4">
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || "my Product", t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
onFileUpload={async (file) => file.name}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
<SurveyInline
|
||||
appUrl={publicDomain}
|
||||
isPreviewMode={true}
|
||||
survey={previewSurvey(projectName || t("common.my_product"), t)}
|
||||
styling={previewStyling}
|
||||
isBrandingEnabled={false}
|
||||
languageCode="default"
|
||||
onFileUpload={async (file) => file.name}
|
||||
autoFocus={false}
|
||||
/>
|
||||
</div>
|
||||
<CreateTeamModal
|
||||
open={createTeamModalOpen}
|
||||
|
||||
@@ -69,7 +69,7 @@ const Page = async (props: ProjectSettingsPageProps) => {
|
||||
/>
|
||||
{projects.length >= 1 && (
|
||||
<Button
|
||||
className="absolute top-5 right-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
className="absolute right-5 top-5 !mt-0 text-slate-500 hover:text-slate-700"
|
||||
variant="ghost"
|
||||
asChild>
|
||||
<Link href={"/"}>
|
||||
|
||||
@@ -109,7 +109,10 @@ export const MainNavigation = ({
|
||||
href: `/environments/${environment.id}/contacts`,
|
||||
name: t("common.contacts"),
|
||||
icon: UserIcon,
|
||||
isActive: pathname?.includes("/contacts") || pathname?.includes("/segments"),
|
||||
isActive:
|
||||
pathname?.includes("/contacts") ||
|
||||
pathname?.includes("/segments") ||
|
||||
pathname?.includes("/attributes"),
|
||||
},
|
||||
{
|
||||
name: t("common.configuration"),
|
||||
@@ -185,7 +188,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:ring-0 focus:ring-transparent focus:outline-none"
|
||||
"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"
|
||||
)}>
|
||||
{isCollapsed ? (
|
||||
<PanelLeftOpenIcon strokeWidth={1.5} />
|
||||
|
||||
@@ -53,7 +53,7 @@ export const WidgetStatusIndicator = ({ environment }: WidgetStatusIndicatorProp
|
||||
<currentStatus.icon />
|
||||
</div>
|
||||
<p className="text-md font-bold text-slate-800 md:text-xl">{currentStatus.title}</p>
|
||||
<p className="w-2/3 text-sm text-balance text-slate-600">{currentStatus.subtitle}</p>
|
||||
<p className="w-2/3 text-balance text-sm text-slate-600">{currentStatus.subtitle}</p>
|
||||
{status === "notImplemented" && (
|
||||
<Button variant="outline" size="sm" className="bg-white" onClick={() => router.refresh()}>
|
||||
<RotateCcwIcon />
|
||||
|
||||
@@ -81,7 +81,7 @@ export const OrganizationBreadcrumb = ({
|
||||
getOrganizationsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort organizations by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setOrganizations(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
|
||||
@@ -82,7 +82,7 @@ export const ProjectBreadcrumb = ({
|
||||
getProjectsForSwitcherAction({ organizationId: currentOrganizationId }).then((result) => {
|
||||
if (result?.data) {
|
||||
// Sort projects by name
|
||||
const sorted = result.data.toSorted((a, b) => a.name.localeCompare(b.name));
|
||||
const sorted = [...result.data].sort((a, b) => a.name.localeCompare(b.name));
|
||||
setProjects(sorted);
|
||||
} else {
|
||||
// Handle server errors or validation errors
|
||||
|
||||
@@ -30,7 +30,7 @@ export const NotificationSwitch = ({
|
||||
const isChecked =
|
||||
notificationType === "unsubscribedOrganizationIds"
|
||||
? !notificationSettings.unsubscribedOrganizationIds?.includes(surveyOrProjectOrOrganizationId)
|
||||
: notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true;
|
||||
: notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true;
|
||||
|
||||
const handleSwitchChange = async () => {
|
||||
setIsLoading(true);
|
||||
@@ -49,8 +49,11 @@ export const NotificationSwitch = ({
|
||||
];
|
||||
}
|
||||
} else {
|
||||
updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId] =
|
||||
!updatedNotificationSettings[notificationType][surveyOrProjectOrOrganizationId];
|
||||
updatedNotificationSettings[notificationType] = {
|
||||
...updatedNotificationSettings[notificationType],
|
||||
[surveyOrProjectOrOrganizationId]:
|
||||
!updatedNotificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId],
|
||||
};
|
||||
}
|
||||
|
||||
const updatedNotificationSettingsActionResponse = await updateNotificationSettingsAction({
|
||||
@@ -78,7 +81,7 @@ export const NotificationSwitch = ({
|
||||
) {
|
||||
switch (notificationType) {
|
||||
case "alert":
|
||||
if (notificationSettings[notificationType][surveyOrProjectOrOrganizationId] === true) {
|
||||
if (notificationSettings[notificationType]?.[surveyOrProjectOrOrganizationId] === true) {
|
||||
handleSwitchChange();
|
||||
toast.success(
|
||||
t(
|
||||
|
||||
@@ -98,7 +98,7 @@ export const PasswordConfirmationModal = ({
|
||||
aria-label="password"
|
||||
aria-required="true"
|
||||
required
|
||||
className="focus:border-brand-dark focus:ring-brand-dark block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
|
||||
className="block w-full rounded-md border-slate-300 shadow-sm focus:border-brand-dark focus:ring-brand-dark sm:text-sm"
|
||||
value={field.value}
|
||||
onChange={(password) => field.onChange(password)}
|
||||
/>
|
||||
|
||||
@@ -38,7 +38,11 @@ const getBadgeConfig = (
|
||||
}
|
||||
};
|
||||
|
||||
export const EnterpriseLicenseStatus = ({ status, gracePeriodEnd, environmentId }: EnterpriseLicenseStatusProps) => {
|
||||
export const EnterpriseLicenseStatus = ({
|
||||
status,
|
||||
gracePeriodEnd,
|
||||
environmentId,
|
||||
}: EnterpriseLicenseStatusProps) => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const [isRechecking, setIsRechecking] = useState(false);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Alert, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { IdBadge } from "@/modules/ui/components/id-badge";
|
||||
import { PageContentWrapper } from "@/modules/ui/components/page-content-wrapper";
|
||||
import { PageHeader } from "@/modules/ui/components/page-header";
|
||||
import packageJson from "@/package.json";
|
||||
import { SettingsCard } from "../../components/SettingsCard";
|
||||
import { DeleteOrganization } from "./components/DeleteOrganization";
|
||||
import { EditOrganizationNameForm } from "./components/EditOrganizationNameForm";
|
||||
@@ -81,7 +82,10 @@ const Page = async (props: { params: Promise<{ environmentId: string }> }) => {
|
||||
</SettingsCard>
|
||||
)}
|
||||
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
<div className="space-y-2">
|
||||
<IdBadge id={organization.id} label={t("common.organization_id")} variant="column" />
|
||||
<IdBadge id={packageJson.version} label={t("common.formbricks_version")} variant="column" />
|
||||
</div>
|
||||
</PageContentWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -4,6 +4,7 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { ZResponseFilterCriteria } from "@formbricks/types/responses";
|
||||
import { getDisplaysBySurveyIdWithContact } from "@/lib/display/service";
|
||||
import { getResponseCountBySurveyId, getResponses } from "@/lib/response/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
@@ -106,3 +107,31 @@ export const getResponseCountAction = authenticatedActionClient
|
||||
|
||||
return getResponseCountBySurveyId(parsedInput.surveyId, parsedInput.filterCriteria);
|
||||
});
|
||||
|
||||
const ZGetDisplaysWithContactAction = z.object({
|
||||
surveyId: ZId,
|
||||
limit: z.number().int().min(1).max(100),
|
||||
offset: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const getDisplaysWithContactAction = authenticatedActionClient
|
||||
.schema(ZGetDisplaysWithContactAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromSurveyId(parsedInput.surveyId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
minPermission: "read",
|
||||
projectId: await getProjectIdFromSurveyId(parsedInput.surveyId),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return getDisplaysBySurveyIdWithContact(parsedInput.surveyId, parsedInput.limit, parsedInput.offset);
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ResponseFilterProvider } from "@/app/(app)/environments/[environmentId]
|
||||
import { getResponseCountBySurveyId } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
import { getTranslate } from "@/lingodotdev/server";
|
||||
|
||||
type Props = {
|
||||
params: Promise<{ surveyId: string; environmentId: string }>;
|
||||
@@ -14,10 +15,11 @@ export const generateMetadata = async (props: Props): Promise<Metadata> => {
|
||||
const session = await getServerSession(authOptions);
|
||||
const survey = await getSurvey(params.surveyId);
|
||||
const responseCount = await getResponseCountBySurveyId(params.surveyId);
|
||||
const t = await getTranslate();
|
||||
|
||||
if (session) {
|
||||
return {
|
||||
title: `${responseCount} Responses | ${survey?.name} Results`,
|
||||
title: `${t("common.count_responses", { count: responseCount })} | ${t("environments.surveys.summary.survey_results", { surveyName: survey?.name })}`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -30,8 +30,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{elementSummary.booked.count}{" "}
|
||||
{elementSummary.booked.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{t("common.count_responses", { count: elementSummary.booked.count })}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.booked.percentage / 100} />
|
||||
@@ -47,8 +46,7 @@ export const CalSummary = ({ elementSummary, survey }: CalSummaryProps) => {
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{elementSummary.skipped.count}{" "}
|
||||
{elementSummary.skipped.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{t("common.count_responses", { count: elementSummary.skipped.count })}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={elementSummary.skipped.percentage / 100} />
|
||||
|
||||
@@ -64,7 +64,7 @@ export const ConsentSummary = ({ elementSummary, survey, setFilter }: ConsentSum
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{summaryItem.count} {summaryItem.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{t("common.count_responses", { count: summaryItem.count })}
|
||||
</p>
|
||||
</div>
|
||||
<div className="group-hover:opacity-80">
|
||||
|
||||
@@ -48,7 +48,7 @@ export const ElementSummaryHeader = ({
|
||||
{showResponses && (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${elementSummary.responseCount} ${t("common.responses")}`}
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
</div>
|
||||
)}
|
||||
{additionalInfo}
|
||||
|
||||
@@ -41,8 +41,7 @@ export const HiddenFieldsSummary = ({ environment, elementSummary, locale }: Hid
|
||||
</div>
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{elementSummary.responseCount}{" "}
|
||||
{elementSummary.responseCount === 1 ? t("common.response") : t("common.responses")}
|
||||
{t("common.count_responses", { count: elementSummary.responseCount })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
||||
if (label) {
|
||||
return label;
|
||||
} else if (percentage !== undefined && totalResponsesForRow !== undefined) {
|
||||
return `${Math.round((percentage / 100) * totalResponsesForRow)} ${t("common.responses")}`;
|
||||
return t("common.count_responses", { count: Math.round((percentage / 100) * totalResponsesForRow) });
|
||||
}
|
||||
return "";
|
||||
};
|
||||
@@ -77,7 +77,7 @@ export const MatrixElementSummary = ({ elementSummary, survey, setFilter }: Matr
|
||||
)}>
|
||||
<button
|
||||
style={{ backgroundColor: `rgba(0,196,184,${getOpacityLevel(percentage)})` }}
|
||||
className="hover:outline-brand-dark m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline"
|
||||
className="m-1 flex h-full w-40 cursor-pointer items-center justify-center rounded p-4 text-sm text-slate-950 hover:outline hover:outline-brand-dark"
|
||||
onClick={() =>
|
||||
setFilter(
|
||||
elementSummary.element.id,
|
||||
|
||||
@@ -75,7 +75,7 @@ export const MultipleChoiceSummary = ({
|
||||
elementSummary.type === "multipleChoiceMulti" ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export const MultipleChoiceSummary = ({
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
{t("common.count_selections", { count: result.count })}
|
||||
</p>
|
||||
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
|
||||
@@ -123,8 +123,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{elementSummary[group]?.count}{" "}
|
||||
{elementSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{t("common.count_responses", { count: elementSummary[group]?.count })}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar
|
||||
@@ -158,7 +157,7 @@ export const NPSSummary = ({ elementSummary, survey, setFilter }: NPSSummaryProp
|
||||
}>
|
||||
<div className="flex h-32 w-full flex-col items-center justify-end">
|
||||
<div
|
||||
className="bg-brand-dark w-full rounded-t-lg border border-slate-200 transition-all group-hover:brightness-110"
|
||||
className="w-full rounded-t-lg border border-slate-200 bg-brand-dark transition-all group-hover:brightness-110"
|
||||
style={{
|
||||
height: `${Math.max(choice.percentage, 2)}%`,
|
||||
opacity,
|
||||
|
||||
@@ -37,7 +37,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
elementSummary.element.allowMulti ? (
|
||||
<div className="flex items-center rounded-lg bg-slate-100 p-2">
|
||||
<InboxIcon className="mr-2 h-4 w-4" />
|
||||
{`${elementSummary.selectionCount} ${t("common.selections")}`}
|
||||
{t("common.count_selections", { count: elementSummary.selectionCount })}
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
@@ -74,7 +74,7 @@ export const PictureChoiceSummary = ({ elementSummary, survey, setFilter }: Pict
|
||||
</div>
|
||||
<div className="flex w-full space-x-2">
|
||||
<p className="flex w-full pt-1 text-slate-600 sm:items-end sm:justify-end sm:pt-0">
|
||||
{result.count} {result.count === 1 ? t("common.selection") : t("common.selections")}
|
||||
{t("common.count_selections", { count: result.count })}
|
||||
</p>
|
||||
<p className="self-end rounded-lg bg-slate-100 px-2 text-slate-700">
|
||||
{convertFloatToNDecimal(result.percentage, 2)}%
|
||||
|
||||
@@ -116,7 +116,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
)
|
||||
}>
|
||||
<div
|
||||
className={`bg-brand-dark h-full ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
className={`h-full bg-brand-dark ${isFirst ? "rounded-tl-lg" : ""} ${isLast ? "rounded-tr-lg" : ""}`}
|
||||
style={{ opacity }}
|
||||
/>
|
||||
</ClickableBarSegment>
|
||||
@@ -198,7 +198,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
</div>
|
||||
</div>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{result.count} {result.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{t("common.count_responses", { count: result.count })}
|
||||
</p>
|
||||
</div>
|
||||
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
|
||||
@@ -215,8 +215,7 @@ export const RatingSummary = ({ elementSummary, survey, setFilter }: RatingSumma
|
||||
<div className="text flex justify-between px-2">
|
||||
<p className="font-semibold text-slate-700">{t("common.dismissed")}</p>
|
||||
<p className="flex w-32 items-end justify-end text-slate-600">
|
||||
{elementSummary.dismissed.count}{" "}
|
||||
{elementSummary.dismissed.count === 1 ? t("common.response") : t("common.responses")}
|
||||
{t("common.count_responses", { count: elementSummary.dismissed.count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircleIcon, InfoIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
|
||||
interface SummaryImpressionsProps {
|
||||
displays: TDisplayWithContact[];
|
||||
isLoading: boolean;
|
||||
hasMore: boolean;
|
||||
displaysError: string | null;
|
||||
environmentId: string;
|
||||
locale: TUserLocale;
|
||||
onLoadMore: () => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const getDisplayContactIdentifier = (display: TDisplayWithContact): string => {
|
||||
if (!display.contact) return "";
|
||||
return display.contact.attributes?.email || display.contact.attributes?.userId || display.contact.id;
|
||||
};
|
||||
|
||||
export const SummaryImpressions = ({
|
||||
displays,
|
||||
isLoading,
|
||||
hasMore,
|
||||
displaysError,
|
||||
environmentId,
|
||||
locale,
|
||||
onLoadMore,
|
||||
onRetry,
|
||||
}: SummaryImpressionsProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const renderContent = () => {
|
||||
if (displaysError) {
|
||||
return (
|
||||
<div className="p-8">
|
||||
<div className="flex flex-col items-center gap-4 text-center">
|
||||
<div className="flex items-center gap-2 text-red-600">
|
||||
<AlertCircleIcon className="h-5 w-5" />
|
||||
<span className="text-sm font-medium">{t("common.error_loading_data")}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">{displaysError}</p>
|
||||
<Button onClick={onRetry} variant="secondary" size="sm">
|
||||
{t("common.try_again")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (displays.length === 0) {
|
||||
return (
|
||||
<div className="p-8 text-center text-sm text-slate-500">
|
||||
{t("environments.surveys.summary.no_identified_impressions")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid min-h-10 grid-cols-4 items-center border-b border-slate-200 bg-slate-100 text-sm font-semibold text-slate-600">
|
||||
<div className="col-span-2 px-4 md:px-6">{t("common.user")}</div>
|
||||
<div className="col-span-2 px-4 md:px-6">{t("environments.contacts.survey_viewed_at")}</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[62vh] overflow-y-auto">
|
||||
{displays.map((display) => (
|
||||
<div
|
||||
key={display.id}
|
||||
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-xs text-slate-800 last:border-transparent md:text-sm">
|
||||
<div className="col-span-2 pl-4 md:pl-6">
|
||||
{display.contact ? (
|
||||
<Link
|
||||
className="ph-no-capture break-all text-slate-600 hover:underline"
|
||||
href={`/environments/${environmentId}/contacts/${display.contact.id}`}>
|
||||
{getDisplayContactIdentifier(display)}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="break-all text-slate-600">{t("common.anonymous")}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 px-4 text-slate-500 md:px-6">
|
||||
{timeSince(display.createdAt.toString(), locale)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMore && (
|
||||
<div className="flex justify-center border-t border-slate-100 py-4">
|
||||
<Button onClick={onLoadMore} variant="secondary" size="sm">
|
||||
{t("common.load_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-8 shadow-sm">
|
||||
<div className="flex items-center justify-center">
|
||||
<div className="h-6 w-32 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||
<div className="flex items-center gap-2 rounded-t-xl border-b border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||
<InfoIcon className="h-4 w-4 shrink-0" />
|
||||
<span>{t("environments.surveys.summary.impressions_identified_only")}</span>
|
||||
</div>
|
||||
{renderContent()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -10,8 +10,8 @@ interface SummaryMetadataProps {
|
||||
surveySummary: TSurveySummary["meta"];
|
||||
quotasCount: number;
|
||||
isLoading: boolean;
|
||||
tab: "dropOffs" | "quotas" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
|
||||
tab: "dropOffs" | "quotas" | "impressions" | undefined;
|
||||
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | "impressions" | undefined>>;
|
||||
isQuotasAllowed: boolean;
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export const SummaryMetadata = ({
|
||||
const { t } = useTranslation();
|
||||
const dropoffCountValue = dropOffCount === 0 ? <span>-</span> : dropOffCount;
|
||||
|
||||
const handleTabChange = (val: "dropOffs" | "quotas") => {
|
||||
const handleTabChange = (val: "dropOffs" | "quotas" | "impressions") => {
|
||||
const change = tab === val ? undefined : val;
|
||||
setTab(change);
|
||||
};
|
||||
@@ -65,12 +65,16 @@ export const SummaryMetadata = ({
|
||||
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
|
||||
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
|
||||
)}>
|
||||
<StatCard
|
||||
<InteractiveCard
|
||||
key="impressions"
|
||||
tab="impressions"
|
||||
label={t("environments.surveys.summary.impressions")}
|
||||
percentage={null}
|
||||
value={displayCount === 0 ? <span>-</span> : displayCount}
|
||||
tooltipText={t("environments.surveys.summary.impressions_tooltip")}
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleTabChange("impressions")}
|
||||
isActive={tab === "impressions"}
|
||||
/>
|
||||
<StatCard
|
||||
label={t("environments.surveys.summary.starts")}
|
||||
|
||||
@@ -1,21 +1,31 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import { TSurvey, TSurveySummary } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { getSurveySummaryAction } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import {
|
||||
getDisplaysWithContactAction,
|
||||
getSurveySummaryAction,
|
||||
} from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/actions";
|
||||
import { useResponseFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/components/response-filter-context";
|
||||
import ScrollToTop from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/ScrollToTop";
|
||||
import { SummaryDropOffs } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryDropOffs";
|
||||
import { SummaryImpressions } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/SummaryImpressions";
|
||||
import { CustomFilter } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/components/CustomFilter";
|
||||
import { getFormattedFilters } from "@/app/lib/surveys/surveys";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { replaceHeadlineRecall } from "@/lib/utils/recall";
|
||||
import { QuotasSummary } from "@/modules/ee/quotas/components/quotas-summary";
|
||||
import { SummaryList } from "./SummaryList";
|
||||
import { SummaryMetadata } from "./SummaryMetadata";
|
||||
|
||||
const DISPLAYS_PER_PAGE = 15;
|
||||
|
||||
const defaultSurveySummary: TSurveySummary = {
|
||||
meta: {
|
||||
completedPercentage: 0,
|
||||
@@ -51,17 +61,76 @@ export const SummaryPage = ({
|
||||
initialSurveySummary,
|
||||
isQuotasAllowed,
|
||||
}: SummaryPageProps) => {
|
||||
const { t } = useTranslation();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [surveySummary, setSurveySummary] = useState<TSurveySummary>(
|
||||
initialSurveySummary || defaultSurveySummary
|
||||
);
|
||||
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | undefined>(undefined);
|
||||
const [tab, setTab] = useState<"dropOffs" | "quotas" | "impressions" | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(!initialSurveySummary);
|
||||
|
||||
const { selectedFilter, dateRange, resetState } = useResponseFilter();
|
||||
|
||||
const [displays, setDisplays] = useState<TDisplayWithContact[]>([]);
|
||||
const [isDisplaysLoading, setIsDisplaysLoading] = useState(false);
|
||||
const [hasMoreDisplays, setHasMoreDisplays] = useState(true);
|
||||
const [displaysError, setDisplaysError] = useState<string | null>(null);
|
||||
const displaysFetchedRef = useRef(false);
|
||||
|
||||
const fetchDisplays = useCallback(
|
||||
async (offset: number) => {
|
||||
const response = await getDisplaysWithContactAction({
|
||||
surveyId,
|
||||
limit: DISPLAYS_PER_PAGE,
|
||||
offset,
|
||||
});
|
||||
|
||||
if (!response?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response?.data ?? [];
|
||||
},
|
||||
[surveyId]
|
||||
);
|
||||
|
||||
const loadInitialDisplays = useCallback(async () => {
|
||||
setIsDisplaysLoading(true);
|
||||
setDisplaysError(null);
|
||||
try {
|
||||
const data = await fetchDisplays(0);
|
||||
setDisplays(data);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
toast.error(error);
|
||||
setDisplays([]);
|
||||
setHasMoreDisplays(false);
|
||||
} finally {
|
||||
setIsDisplaysLoading(false);
|
||||
}
|
||||
}, [fetchDisplays, t]);
|
||||
|
||||
const handleLoadMoreDisplays = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchDisplays(displays.length);
|
||||
setDisplays((prev) => [...prev, ...data]);
|
||||
setHasMoreDisplays(data.length === DISPLAYS_PER_PAGE);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : t("common.something_went_wrong");
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
}, [fetchDisplays, displays.length, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tab === "impressions" && !displaysFetchedRef.current) {
|
||||
displaysFetchedRef.current = true;
|
||||
loadInitialDisplays();
|
||||
}
|
||||
}, [tab, loadInitialDisplays]);
|
||||
|
||||
// Only fetch data when filters change or when there's no initial data
|
||||
useEffect(() => {
|
||||
// If we have initial data and no filters are applied, don't fetch
|
||||
@@ -121,6 +190,18 @@ export const SummaryPage = ({
|
||||
setTab={setTab}
|
||||
isQuotasAllowed={isQuotasAllowed}
|
||||
/>
|
||||
{tab === "impressions" && (
|
||||
<SummaryImpressions
|
||||
displays={displays}
|
||||
isLoading={isDisplaysLoading}
|
||||
hasMore={hasMoreDisplays}
|
||||
displaysError={displaysError}
|
||||
environmentId={environment.id}
|
||||
locale={locale}
|
||||
onLoadMore={handleLoadMoreDisplays}
|
||||
onRetry={loadInitialDisplays}
|
||||
/>
|
||||
)}
|
||||
{tab === "dropOffs" && <SummaryDropOffs dropOff={surveySummary.dropOff} survey={surveyMemoized} />}
|
||||
{isQuotasAllowed && tab === "quotas" && <QuotasSummary quotas={surveySummary.quotas} />}
|
||||
<div className="flex gap-1.5">
|
||||
|
||||
@@ -4,9 +4,9 @@ import { ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
||||
import { BaseCard } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/components/base-card";
|
||||
|
||||
interface InteractiveCardProps {
|
||||
tab: "dropOffs" | "quotas";
|
||||
tab: "dropOffs" | "quotas" | "impressions";
|
||||
label: string;
|
||||
percentage: number;
|
||||
percentage: number | null;
|
||||
value: React.ReactNode;
|
||||
tooltipText: string;
|
||||
isLoading: boolean;
|
||||
|
||||
@@ -352,7 +352,7 @@ export const AnonymousLinksTab = ({
|
||||
},
|
||||
{
|
||||
title: t("environments.surveys.share.anonymous_links.custom_start_point"),
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-question",
|
||||
href: "https://formbricks.com/docs/xm-and-surveys/surveys/link-surveys/start-at-block",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -105,7 +105,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
||||
<div className={scriptsMode === "replace" ? "opacity-50" : ""}>
|
||||
<FormLabel>{t("environments.surveys.share.custom_html.workspace_scripts_label")}</FormLabel>
|
||||
<div className="mt-2 max-h-32 overflow-auto rounded-md border border-slate-200 bg-slate-50 p-3">
|
||||
<pre className="font-mono text-xs whitespace-pre-wrap text-slate-600">
|
||||
<pre className="whitespace-pre-wrap font-mono text-xs text-slate-600">
|
||||
{projectCustomScripts}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -135,7 +135,7 @@ export const CustomHtmlTab = ({ projectCustomScripts, isReadOnly }: CustomHtmlTa
|
||||
rows={8}
|
||||
placeholder={t("environments.surveys.share.custom_html.placeholder")}
|
||||
className={cn(
|
||||
"focus:border-brand-dark flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50"
|
||||
"flex w-full rounded-md border border-slate-300 bg-white px-3 py-2 font-mono text-xs text-slate-800 placeholder:text-slate-400 focus:border-brand-dark focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
)}
|
||||
{...field}
|
||||
disabled={isReadOnly}
|
||||
|
||||
@@ -66,7 +66,7 @@ export const SuccessView: React.FC<SuccessViewProps> = ({
|
||||
className="relative flex flex-col items-center gap-3 rounded-lg border border-slate-100 bg-white p-4 text-center text-sm text-slate-900 hover:border-slate-200 md:p-8">
|
||||
<UserIcon className="h-8 w-8 stroke-1 text-slate-900" />
|
||||
{t("environments.surveys.summary.use_personal_links")}
|
||||
<Badge size="normal" type="success" className="absolute top-3 right-3" text={t("common.new")} />
|
||||
<Badge size="normal" type="success" className="absolute right-3 top-3" text={t("common.new")} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/environments/${environmentId}/settings/notifications`}
|
||||
|
||||
@@ -192,7 +192,7 @@ export const ElementsComboBox = ({ options, selected, onChangeValue }: ElementCo
|
||||
value={inputValue}
|
||||
onValueChange={setInputValue}
|
||||
placeholder={open ? `${t("common.search")}...` : t("common.select_filter")}
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none ring-offset-transparent outline-none focus:border-none focus:shadow-none focus:ring-offset-0 focus:outline-none"
|
||||
className="max-w-full grow border-none p-0 pl-2 text-sm shadow-none outline-none ring-offset-transparent focus:border-none focus:shadow-none focus:outline-none focus:ring-offset-0"
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -241,7 +241,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<PopoverTrigger asChild>
|
||||
<PopoverTriggerButton isOpen={isOpen}>
|
||||
Filter <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
{t("common.filter")} <b>{activeFilterCount > 0 && `(${activeFilterCount})`}</b>
|
||||
</PopoverTriggerButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
@@ -329,7 +329,7 @@ export const ResponseFilter = ({ survey }: ResponseFilterProps) => {
|
||||
</div>
|
||||
{i !== filterValue.filter.length - 1 && (
|
||||
<div className="my-4 flex items-center">
|
||||
<p className="mr-4 font-semibold text-slate-800">and</p>
|
||||
<p className="mr-4 font-semibold text-slate-800">{t("common.and")}</p>
|
||||
<hr className="w-full text-slate-600" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,12 +1,49 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { ZIntegrationGoogleSheets } from "@formbricks/types/integration/google-sheet";
|
||||
import { getSpreadsheetNameById } from "@/lib/googleSheet/service";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
ZIntegrationGoogleSheets,
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { getSpreadsheetNameById, validateGoogleSheetsConnection } from "@/lib/googleSheet/service";
|
||||
import { getIntegrationByType } from "@/lib/integration/service";
|
||||
import { authenticatedActionClient } from "@/lib/utils/action-client";
|
||||
import { checkAuthorizationUpdated } from "@/lib/utils/action-client/action-client-middleware";
|
||||
import { getOrganizationIdFromEnvironmentId, getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
|
||||
|
||||
const ZValidateGoogleSheetsConnectionAction = z.object({
|
||||
environmentId: ZId,
|
||||
});
|
||||
|
||||
export const validateGoogleSheetsConnectionAction = authenticatedActionClient
|
||||
.schema(ZValidateGoogleSheetsConnectionAction)
|
||||
.action(async ({ ctx, parsedInput }) => {
|
||||
await checkAuthorizationUpdated({
|
||||
userId: ctx.user.id,
|
||||
organizationId: await getOrganizationIdFromEnvironmentId(parsedInput.environmentId),
|
||||
access: [
|
||||
{
|
||||
type: "organization",
|
||||
roles: ["owner", "manager"],
|
||||
},
|
||||
{
|
||||
type: "projectTeam",
|
||||
projectId: await getProjectIdFromEnvironmentId(parsedInput.environmentId),
|
||||
minPermission: "readWrite",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const integration = await getIntegrationByType(parsedInput.environmentId, "googleSheets");
|
||||
if (!integration) {
|
||||
return { data: false };
|
||||
}
|
||||
|
||||
await validateGoogleSheetsConnection(integration as TIntegrationGoogleSheets);
|
||||
return { data: true };
|
||||
});
|
||||
|
||||
const ZGetSpreadsheetNameByIdAction = z.object({
|
||||
googleSheetIntegration: ZIntegrationGoogleSheets,
|
||||
environmentId: z.string(),
|
||||
|
||||
@@ -20,6 +20,10 @@ import {
|
||||
isValidGoogleSheetsUrl,
|
||||
} from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/util";
|
||||
import GoogleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import {
|
||||
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
|
||||
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
|
||||
} from "@/lib/googleSheet/constants";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { recallToHeadline } from "@/lib/utils/recall";
|
||||
import { getElementsFromBlocks } from "@/modules/survey/lib/client-utils";
|
||||
@@ -118,6 +122,17 @@ export const AddIntegrationModal = ({
|
||||
resetForm();
|
||||
}, [selectedIntegration, surveys]);
|
||||
|
||||
const showErrorMessageToast = (response: Awaited<ReturnType<typeof getSpreadsheetNameByIdAction>>) => {
|
||||
const errorMessage = getFormattedErrorMessage(response);
|
||||
if (errorMessage === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
|
||||
toast.error(t("environments.integrations.google_sheets.token_expired_error"));
|
||||
} else if (errorMessage === GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION) {
|
||||
toast.error(t("environments.integrations.google_sheets.spreadsheet_permission_error"));
|
||||
} else {
|
||||
toast.error(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
const linkSheet = async () => {
|
||||
try {
|
||||
if (!isValidGoogleSheetsUrl(spreadsheetUrl)) {
|
||||
@@ -129,6 +144,7 @@ export const AddIntegrationModal = ({
|
||||
if (selectedElements.length === 0) {
|
||||
throw new Error(t("environments.integrations.select_at_least_one_question_error"));
|
||||
}
|
||||
setIsLinkingSheet(true);
|
||||
const spreadsheetId = extractSpreadsheetIdFromUrl(spreadsheetUrl);
|
||||
const spreadsheetNameResponse = await getSpreadsheetNameByIdAction({
|
||||
googleSheetIntegration,
|
||||
@@ -137,13 +153,11 @@ export const AddIntegrationModal = ({
|
||||
});
|
||||
|
||||
if (!spreadsheetNameResponse?.data) {
|
||||
const errorMessage = getFormattedErrorMessage(spreadsheetNameResponse);
|
||||
throw new Error(errorMessage);
|
||||
showErrorMessageToast(spreadsheetNameResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
const spreadsheetName = spreadsheetNameResponse.data;
|
||||
|
||||
setIsLinkingSheet(true);
|
||||
integrationData.spreadsheetId = spreadsheetId;
|
||||
integrationData.spreadsheetName = spreadsheetName;
|
||||
integrationData.surveyId = selectedSurvey.id;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { TEnvironment } from "@formbricks/types/environment";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
} from "@formbricks/types/integration/google-sheet";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { TUserLocale } from "@formbricks/types/user";
|
||||
import { validateGoogleSheetsConnectionAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/actions";
|
||||
import { ManageIntegration } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/components/ManageIntegration";
|
||||
import { authorize } from "@/app/(app)/environments/[environmentId]/workspace/integrations/google-sheets/lib/google";
|
||||
import googleSheetLogo from "@/images/googleSheetsLogo.png";
|
||||
import { GOOGLE_SHEET_INTEGRATION_INVALID_GRANT } from "@/lib/googleSheet/constants";
|
||||
import { ConnectIntegration } from "@/modules/ui/components/connect-integration";
|
||||
import { AddIntegrationModal } from "./AddIntegrationModal";
|
||||
|
||||
@@ -35,10 +37,23 @@ export const GoogleSheetWrapper = ({
|
||||
googleSheetIntegration ? googleSheetIntegration.config?.key : false
|
||||
);
|
||||
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
|
||||
const [showReconnectButton, setShowReconnectButton] = useState<boolean>(false);
|
||||
const [selectedIntegration, setSelectedIntegration] = useState<
|
||||
(TIntegrationGoogleSheetsConfigData & { index: number }) | null
|
||||
>(null);
|
||||
|
||||
const validateConnection = useCallback(async () => {
|
||||
if (!isConnected || !googleSheetIntegration) return;
|
||||
const response = await validateGoogleSheetsConnectionAction({ environmentId: environment.id });
|
||||
if (response?.serverError === GOOGLE_SHEET_INTEGRATION_INVALID_GRANT) {
|
||||
setShowReconnectButton(true);
|
||||
}
|
||||
}, [environment.id, isConnected, googleSheetIntegration]);
|
||||
|
||||
useEffect(() => {
|
||||
validateConnection();
|
||||
}, [validateConnection]);
|
||||
|
||||
const handleGoogleAuthorization = async () => {
|
||||
authorize(environment.id, webAppUrl).then((url: string) => {
|
||||
if (url) {
|
||||
@@ -64,6 +79,8 @@ export const GoogleSheetWrapper = ({
|
||||
setOpenAddIntegrationModal={setIsModalOpen}
|
||||
setIsConnected={setIsConnected}
|
||||
setSelectedIntegration={setSelectedIntegration}
|
||||
showReconnectButton={showReconnectButton}
|
||||
handleGoogleAuthorization={handleGoogleAuthorization}
|
||||
locale={locale}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Trash2Icon } from "lucide-react";
|
||||
import { RefreshCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -12,15 +12,19 @@ import { TUserLocale } from "@formbricks/types/user";
|
||||
import { deleteIntegrationAction } from "@/app/(app)/environments/[environmentId]/workspace/integrations/actions";
|
||||
import { timeSince } from "@/lib/time";
|
||||
import { getFormattedErrorMessage } from "@/lib/utils/helper";
|
||||
import { Alert, AlertButton, AlertDescription } from "@/modules/ui/components/alert";
|
||||
import { Button } from "@/modules/ui/components/button";
|
||||
import { DeleteDialog } from "@/modules/ui/components/delete-dialog";
|
||||
import { EmptyState } from "@/modules/ui/components/empty-state";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
|
||||
|
||||
interface ManageIntegrationProps {
|
||||
googleSheetIntegration: TIntegrationGoogleSheets;
|
||||
setOpenAddIntegrationModal: (v: boolean) => void;
|
||||
setIsConnected: (v: boolean) => void;
|
||||
setSelectedIntegration: (v: (TIntegrationGoogleSheetsConfigData & { index: number }) | null) => void;
|
||||
showReconnectButton: boolean;
|
||||
handleGoogleAuthorization: () => void;
|
||||
locale: TUserLocale;
|
||||
}
|
||||
|
||||
@@ -29,6 +33,8 @@ export const ManageIntegration = ({
|
||||
setOpenAddIntegrationModal,
|
||||
setIsConnected,
|
||||
setSelectedIntegration,
|
||||
showReconnectButton,
|
||||
handleGoogleAuthorization,
|
||||
locale,
|
||||
}: ManageIntegrationProps) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -68,7 +74,17 @@ export const ManageIntegration = ({
|
||||
|
||||
return (
|
||||
<div className="mt-6 flex w-full flex-col items-center justify-center p-6">
|
||||
<div className="flex w-full justify-end">
|
||||
{showReconnectButton && (
|
||||
<Alert variant="warning" size="small" className="mb-4 w-full">
|
||||
<AlertDescription>
|
||||
{t("environments.integrations.google_sheets.reconnect_button_description")}
|
||||
</AlertDescription>
|
||||
<AlertButton onClick={handleGoogleAuthorization}>
|
||||
{t("environments.integrations.google_sheets.reconnect_button")}
|
||||
</AlertButton>
|
||||
</Alert>
|
||||
)}
|
||||
<div className="flex w-full justify-end space-x-2">
|
||||
<div className="mr-6 flex items-center">
|
||||
<span className="mr-4 h-4 w-4 rounded-full bg-green-600"></span>
|
||||
<span className="text-slate-500">
|
||||
@@ -77,6 +93,19 @@ export const ManageIntegration = ({
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="outline" onClick={handleGoogleAuthorization}>
|
||||
<RefreshCcwIcon className="mr-2 h-4 w-4" />
|
||||
{t("environments.integrations.google_sheets.reconnect_button")}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{t("environments.integrations.google_sheets.reconnect_button_tooltip")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setSelectedIntegration(null);
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("environments.integrations.google_sheets.link_new_sheet")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -51,7 +51,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -10,7 +10,7 @@ const Loading = () => {
|
||||
<div className="mt-6 p-6">
|
||||
<GoBackButton />
|
||||
<div className="mb-6 text-right">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed bg-slate-200 select-none">
|
||||
<Button className="pointer-events-none animate-pulse cursor-not-allowed select-none bg-slate-200">
|
||||
{t("environments.integrations.notion.link_database")}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -48,7 +48,7 @@ const Loading = () => {
|
||||
<div className="mt-0 h-4 w-24 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-2 my-auto flex items-center justify-center text-center text-sm whitespace-nowrap text-slate-500">
|
||||
<div className="col-span-2 my-auto flex items-center justify-center whitespace-nowrap text-center text-sm text-slate-500">
|
||||
<div className="h-4 w-16 animate-pulse rounded-full bg-slate-200"></div>
|
||||
</div>
|
||||
<div className="text-center"></div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getFormattedDateTimeString } from "@/lib/utils/datetime";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { truncateText } from "@/lib/utils/strings";
|
||||
import { resolveStorageUrlAuto } from "@/modules/storage/utils";
|
||||
|
||||
const convertMetaObjectToString = (metadata: TResponseMeta): string => {
|
||||
let result: string[] = [];
|
||||
@@ -256,10 +257,16 @@ const processElementResponse = (
|
||||
const selectedChoiceIds = responseValue as string[];
|
||||
return element.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl)
|
||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
if (element.type === TSurveyElementTypeEnum.FileUpload && Array.isArray(responseValue)) {
|
||||
return responseValue
|
||||
.map((url) => (typeof url === "string" ? resolveStorageUrlAuto(url) : url))
|
||||
.join("; ");
|
||||
}
|
||||
|
||||
return processResponseData(responseValue);
|
||||
};
|
||||
|
||||
@@ -368,7 +375,7 @@ const buildNotionPayloadProperties = (
|
||||
|
||||
responses[resp] = (pictureElement as any)?.choices
|
||||
.filter((choice) => selectedChoiceIds.includes(choice.id))
|
||||
.map((choice) => choice.imageUrl);
|
||||
.map((choice) => resolveStorageUrlAuto(choice.imageUrl));
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import { convertDatesInObject } from "@/lib/time";
|
||||
import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler";
|
||||
import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log";
|
||||
import { sendResponseFinishedEmail } from "@/modules/email";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { sendFollowUpsForResponse } from "@/modules/survey/follow-ups/lib/follow-ups";
|
||||
import { FollowUpSendError } from "@/modules/survey/follow-ups/types/follow-up";
|
||||
import { handleIntegrations } from "./lib/handleIntegrations";
|
||||
@@ -30,7 +31,10 @@ export const POST = async (request: Request) => {
|
||||
}
|
||||
|
||||
const jsonInput = await request.json();
|
||||
const convertedJsonInput = convertDatesInObject(jsonInput);
|
||||
const convertedJsonInput = convertDatesInObject(
|
||||
jsonInput,
|
||||
new Set(["contactAttributes", "variables", "data", "meta"])
|
||||
);
|
||||
|
||||
const inputValidation = ZPipelineInput.safeParse(convertedJsonInput);
|
||||
|
||||
@@ -92,12 +96,15 @@ export const POST = async (request: Request) => {
|
||||
]);
|
||||
};
|
||||
|
||||
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
|
||||
|
||||
const webhookPromises = webhooks.map((webhook) => {
|
||||
const body = JSON.stringify({
|
||||
webhookId: webhook.id,
|
||||
event,
|
||||
data: {
|
||||
...response,
|
||||
data: resolvedResponseData,
|
||||
survey: {
|
||||
title: survey.name,
|
||||
type: survey.type,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { google } from "googleapis";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { TIntegrationGoogleSheetsConfig } from "@formbricks/types/integration/google-sheet";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
@@ -6,18 +8,29 @@ import {
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
|
||||
export const GET = async (req: Request) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
const code = queryParams.get("code");
|
||||
const url = new URL(req.url);
|
||||
const environmentId = url.searchParams.get("state");
|
||||
const code = url.searchParams.get("code");
|
||||
|
||||
if (!environmentId) {
|
||||
return responses.badRequestResponse("Invalid environmentId");
|
||||
}
|
||||
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) {
|
||||
return responses.notAuthenticatedResponse();
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(session.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return responses.unauthorizedResponse();
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return responses.badRequestResponse("`code` must be a string");
|
||||
}
|
||||
@@ -30,33 +43,39 @@ export const GET = async (req: Request) => {
|
||||
if (!redirect_uri) return responses.internalServerErrorResponse("Google redirect url is missing");
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
|
||||
let key;
|
||||
let userEmail;
|
||||
|
||||
if (code) {
|
||||
const token = await oAuth2Client.getToken(code);
|
||||
key = token.res?.data;
|
||||
|
||||
// Set credentials using the provided token
|
||||
oAuth2Client.setCredentials({
|
||||
access_token: key.access_token,
|
||||
});
|
||||
|
||||
// Fetch user's email
|
||||
const oauth2 = google.oauth2({
|
||||
auth: oAuth2Client,
|
||||
version: "v2",
|
||||
});
|
||||
const userInfo = await oauth2.userinfo.get();
|
||||
userEmail = userInfo.data.email;
|
||||
if (!code) {
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
);
|
||||
}
|
||||
|
||||
const token = await oAuth2Client.getToken(code);
|
||||
const key = token.res?.data;
|
||||
if (!key) {
|
||||
return Response.redirect(
|
||||
`${WEBAPP_URL}/environments/${environmentId}/workspace/integrations/google-sheets`
|
||||
);
|
||||
}
|
||||
|
||||
oAuth2Client.setCredentials({ access_token: key.access_token });
|
||||
const oauth2 = google.oauth2({ auth: oAuth2Client, version: "v2" });
|
||||
const userInfo = await oauth2.userinfo.get();
|
||||
const userEmail = userInfo.data.email;
|
||||
|
||||
if (!userEmail) {
|
||||
return responses.internalServerErrorResponse("Failed to get user email");
|
||||
}
|
||||
|
||||
const integrationType = "googleSheets" as const;
|
||||
const existingIntegration = await getIntegrationByType(environmentId, integrationType);
|
||||
const existingConfig = existingIntegration?.config as TIntegrationGoogleSheetsConfig;
|
||||
|
||||
const googleSheetIntegration = {
|
||||
type: "googleSheets" as "googleSheets",
|
||||
type: integrationType,
|
||||
environment: environmentId,
|
||||
config: {
|
||||
key,
|
||||
data: [],
|
||||
data: existingConfig?.data ?? [],
|
||||
email: userEmail,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
// Deprecated: This api route is deprecated now and will be removed in the future.
|
||||
// Deprecated: This is currently only being used for the older react native SDKs. Please upgrade to the latest SDKs.
|
||||
import { NextRequest, userAgent } from "next/server";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { TJsPeopleUserIdInput, ZJsPeopleUserIdInput } from "@formbricks/types/js";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getContactByUserId } from "@/app/api/v1/client/[environmentId]/app/sync/lib/contact";
|
||||
import { getSyncSurveys } from "@/app/api/v1/client/[environmentId]/app/sync/lib/survey";
|
||||
import { replaceAttributeRecall } from "@/app/api/v1/client/[environmentId]/app/sync/lib/utils";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { transformErrorToDetails } from "@/app/lib/api/validator";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { getActionClasses } from "@/lib/actionClass/service";
|
||||
import { IS_FORMBRICKS_CLOUD } from "@/lib/constants";
|
||||
import { getEnvironment, updateEnvironment } from "@/lib/environment/service";
|
||||
import {
|
||||
getMonthlyOrganizationResponseCount,
|
||||
getOrganizationByEnvironmentId,
|
||||
} from "@/lib/organization/service";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { COLOR_DEFAULTS } from "@/lib/styling/constants";
|
||||
|
||||
const validateInput = (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): { isValid: true; data: TJsPeopleUserIdInput } | { isValid: false; error: Response } => {
|
||||
const inputValidation = ZJsPeopleUserIdInput.safeParse({ environmentId, userId });
|
||||
if (!inputValidation.success) {
|
||||
return {
|
||||
isValid: false,
|
||||
error: responses.badRequestResponse(
|
||||
"Fields are missing or incorrectly formatted",
|
||||
transformErrorToDetails(inputValidation.error),
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
return { isValid: true, data: inputValidation.data };
|
||||
};
|
||||
|
||||
const checkResponseLimit = async (environmentId: string): Promise<boolean> => {
|
||||
if (!IS_FORMBRICKS_CLOUD) return false;
|
||||
|
||||
const organization = await getOrganizationByEnvironmentId(environmentId);
|
||||
if (!organization) {
|
||||
logger.error({ environmentId }, "Organization does not exist");
|
||||
|
||||
// fail closed if the organization does not exist
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentResponseCount = await getMonthlyOrganizationResponseCount(organization.id);
|
||||
const monthlyResponseLimit = organization.billing.limits.monthly.responses;
|
||||
const isLimitReached = monthlyResponseLimit !== null && currentResponseCount >= monthlyResponseLimit;
|
||||
|
||||
return isLimitReached;
|
||||
};
|
||||
|
||||
export const OPTIONS = async (): Promise<Response> => {
|
||||
return responses.successResponse({}, true);
|
||||
};
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({
|
||||
req,
|
||||
props,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
props: { params: Promise<{ environmentId: string; userId: string }> };
|
||||
}) => {
|
||||
const params = await props.params;
|
||||
try {
|
||||
const { device } = userAgent(req);
|
||||
|
||||
// validate using zod
|
||||
const validation = validateInput(params.environmentId, params.userId);
|
||||
if (!validation.isValid) {
|
||||
return { response: validation.error };
|
||||
}
|
||||
|
||||
const { environmentId, userId } = validation.data;
|
||||
|
||||
const environment = await getEnvironment(environmentId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment does not exist");
|
||||
}
|
||||
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
if (!environment.appSetupCompleted) {
|
||||
await updateEnvironment(environment.id, { appSetupCompleted: true });
|
||||
}
|
||||
|
||||
// check organization subscriptions and response limits
|
||||
const isAppSurveyResponseLimitReached = await checkResponseLimit(environmentId);
|
||||
|
||||
let contact = await getContactByUserId(environmentId, userId);
|
||||
if (!contact) {
|
||||
contact = await prisma.contact.create({
|
||||
data: {
|
||||
attributes: {
|
||||
create: {
|
||||
attributeKey: {
|
||||
connect: {
|
||||
key_environmentId: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
},
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
environment: { connect: { id: environmentId } },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const contactAttributes = contact.attributes.reduce((acc, attribute) => {
|
||||
acc[attribute.attributeKey.key] = attribute.value;
|
||||
return acc;
|
||||
}, {}) as Record<string, string>;
|
||||
|
||||
const [surveys, actionClasses] = await Promise.all([
|
||||
getSyncSurveys(
|
||||
environmentId,
|
||||
contact.id,
|
||||
contactAttributes,
|
||||
device.type === "mobile" ? "phone" : "desktop"
|
||||
),
|
||||
getActionClasses(environmentId),
|
||||
]);
|
||||
|
||||
const updatedProject: any = {
|
||||
...project,
|
||||
brandColor: project.styling.brandColor?.light ?? COLOR_DEFAULTS.brandColor,
|
||||
...(project.styling.highlightBorderColor?.light && {
|
||||
highlightBorderColor: project.styling.highlightBorderColor.light,
|
||||
}),
|
||||
};
|
||||
|
||||
const language = contactAttributes["language"];
|
||||
|
||||
// Scenario 1: Multi language and updated trigger action classes supported.
|
||||
// Use the surveys as they are.
|
||||
let transformedSurveys: TSurvey[] = surveys;
|
||||
|
||||
// creating state object
|
||||
let state = {
|
||||
surveys: !isAppSurveyResponseLimitReached
|
||||
? transformedSurveys.map((survey) => replaceAttributeRecall(survey, contactAttributes))
|
||||
: [],
|
||||
actionClasses,
|
||||
language,
|
||||
project: updatedProject,
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse({ ...state }, true),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error({ error, url: req.url }, "Error in GET /api/v1/client/[environmentId]/app/sync/[userId]");
|
||||
return {
|
||||
response: responses.internalServerErrorResponse(
|
||||
"Unable to handle the request: " + error.message,
|
||||
true
|
||||
),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1,83 +0,0 @@
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { TContact } from "@/modules/ee/contacts/types/contact";
|
||||
import { getContactByUserId } from "./contact";
|
||||
|
||||
// Mock prisma
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
contact: {
|
||||
findFirst: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-environment-id";
|
||||
const userId = "test-user-id";
|
||||
const contactId = "test-contact-id";
|
||||
|
||||
const contactMock: Partial<TContact> & {
|
||||
attributes: { value: string; attributeKey: { key: string } }[];
|
||||
} = {
|
||||
id: contactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "userId" }, value: userId },
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
],
|
||||
};
|
||||
|
||||
describe("getContactByUserId", () => {
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should return contact if found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(contactMock as any);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
expect(contact).toEqual(contactMock);
|
||||
});
|
||||
|
||||
test("should return null if contact not found", async () => {
|
||||
vi.mocked(prisma.contact.findFirst).mockResolvedValue(null);
|
||||
|
||||
const contact = await getContactByUserId(environmentId, userId);
|
||||
|
||||
expect(prisma.contact.findFirst).toHaveBeenCalledWith({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
expect(contact).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
import "server-only";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
|
||||
export const getContactByUserId = reactCache(
|
||||
async (
|
||||
environmentId: string,
|
||||
userId: string
|
||||
): Promise<{
|
||||
attributes: {
|
||||
value: string;
|
||||
attributeKey: {
|
||||
key: string;
|
||||
};
|
||||
}[];
|
||||
id: string;
|
||||
} | null> => {
|
||||
const contact = await prisma.contact.findFirst({
|
||||
where: {
|
||||
attributes: {
|
||||
some: {
|
||||
attributeKey: {
|
||||
key: "userId",
|
||||
environmentId,
|
||||
},
|
||||
value: userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
attributes: { select: { attributeKey: { select: { key: true } }, value: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!contact) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return contact;
|
||||
}
|
||||
);
|
||||
@@ -1,323 +0,0 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { TProject } from "@formbricks/types/project";
|
||||
import { TSegment } from "@formbricks/types/segment";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||
import { diffInDays } from "@/lib/utils/datetime";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
import { getSyncSurveys } from "./survey";
|
||||
|
||||
vi.mock("@/lib/project/service", () => ({
|
||||
getProjectByEnvironmentId: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/survey/service", () => ({
|
||||
getSurveys: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/survey/utils", () => ({
|
||||
anySurveyHasFilters: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/datetime", () => ({
|
||||
diffInDays: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/utils/validate", () => ({
|
||||
validateInputs: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/modules/ee/contacts/segments/lib/segments", () => ({
|
||||
evaluateSegment: vi.fn(),
|
||||
}));
|
||||
vi.mock("@formbricks/database", () => ({
|
||||
prisma: {
|
||||
display: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
response: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("@formbricks/logger", () => ({
|
||||
logger: {
|
||||
error: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const environmentId = "test-env-id";
|
||||
const contactId = "test-contact-id";
|
||||
const contactAttributes = { userId: "user1", email: "test@example.com" };
|
||||
const deviceType = "desktop";
|
||||
|
||||
const mockProject = {
|
||||
id: "proj1",
|
||||
name: "Test Project",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
organizationId: "org1",
|
||||
environments: [],
|
||||
recontactDays: 10,
|
||||
inAppSurveyBranding: true,
|
||||
linkSurveyBranding: true,
|
||||
placement: "bottomRight",
|
||||
clickOutsideClose: true,
|
||||
overlay: "none",
|
||||
languages: [],
|
||||
} as unknown as TProject;
|
||||
|
||||
const baseSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey 1",
|
||||
environmentId: environmentId,
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
displayOption: "displayOnce",
|
||||
recontactDays: null,
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
surveyClosedMessage: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
pin: null,
|
||||
displayLimit: null,
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
endings: [],
|
||||
triggers: [],
|
||||
languages: [],
|
||||
variables: [],
|
||||
hiddenFields: { enabled: false },
|
||||
createdBy: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
projectOverwrites: null,
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
followUps: [],
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
};
|
||||
|
||||
// Helper function to create mock display objects
|
||||
const createMockDisplay = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
|
||||
id,
|
||||
createdAt: createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
surveyId,
|
||||
contactId,
|
||||
responseId: null,
|
||||
status: null,
|
||||
});
|
||||
|
||||
// Helper function to create mock response objects
|
||||
const createMockResponse = (id: string, surveyId: string, contactId: string, createdAt?: Date) => ({
|
||||
id,
|
||||
createdAt: createdAt || new Date(),
|
||||
updatedAt: new Date(),
|
||||
finished: false,
|
||||
surveyId,
|
||||
contactId,
|
||||
endingId: null,
|
||||
data: {},
|
||||
variables: {},
|
||||
ttc: {},
|
||||
meta: {},
|
||||
contactAttributes: null,
|
||||
singleUseId: null,
|
||||
language: null,
|
||||
displayId: null,
|
||||
});
|
||||
|
||||
describe("getSyncSurveys", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(mockProject);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||
vi.mocked(diffInDays).mockReturnValue(100); // Assume enough days passed
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
test("should throw error if product not found", async () => {
|
||||
vi.mocked(getProjectByEnvironmentId).mockResolvedValue(null);
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
"Project not found"
|
||||
);
|
||||
});
|
||||
|
||||
test("should return empty array if no surveys found", async () => {
|
||||
vi.mocked(getSurveys).mockResolvedValue([]);
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should return empty array if no 'app' type surveys in progress", async () => {
|
||||
const surveys: TSurvey[] = [
|
||||
{ ...baseSurvey, id: "s1", type: "link", status: "inProgress" },
|
||||
{ ...baseSurvey, id: "s2", type: "app", status: "paused" },
|
||||
];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displayOnce'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayOnce" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Already displayed
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]); // Not displayed yet
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displayMultiple'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displayMultiple" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]); // Already responded
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([]); // Not responded yet
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by displayOption 'displaySome'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "displaySome", displayLimit: 2 }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
createMockDisplay("d1", "s1", contactId),
|
||||
createMockDisplay("d2", "s1", contactId),
|
||||
]); // Display limit reached
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]); // Within limit
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
|
||||
// Test with response already submitted
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
|
||||
const result3 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result3).toEqual([]);
|
||||
});
|
||||
|
||||
test("should not filter by displayOption 'respondMultiple'", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", displayOption: "respondMultiple" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([createMockDisplay("d1", "s1", contactId)]);
|
||||
vi.mocked(prisma.response.findMany).mockResolvedValue([createMockResponse("r1", "s1", contactId)]);
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should filter by product recontactDays if survey recontactDays is null", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", recontactDays: null }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
const displayDate = new Date();
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
createMockDisplay("d1", "s2", contactId, displayDate), // Display for another survey
|
||||
]);
|
||||
|
||||
vi.mocked(diffInDays).mockReturnValue(5); // Not enough days passed (product.recontactDays = 10)
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]);
|
||||
expect(diffInDays).toHaveBeenCalledWith(expect.any(Date), displayDate);
|
||||
|
||||
vi.mocked(diffInDays).mockReturnValue(15); // Enough days passed
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual(surveys);
|
||||
});
|
||||
|
||||
test("should return surveys if no segment filters exist", async () => {
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1" }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(false);
|
||||
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual(surveys);
|
||||
expect(evaluateSegment).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should evaluate segment filters if they exist", async () => {
|
||||
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||
|
||||
// Case 1: Segment evaluation matches
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(true);
|
||||
const result1 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result1).toEqual(surveys);
|
||||
expect(evaluateSegment).toHaveBeenCalledWith(
|
||||
{
|
||||
attributes: contactAttributes,
|
||||
deviceType,
|
||||
environmentId,
|
||||
contactId,
|
||||
userId: contactAttributes.userId,
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
|
||||
// Case 2: Segment evaluation does not match
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(false);
|
||||
const result2 = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result2).toEqual([]);
|
||||
});
|
||||
|
||||
test("should handle Prisma errors", async () => {
|
||||
const prismaError = new Prisma.PrismaClientKnownRequestError("Test Prisma Error", {
|
||||
code: "P2025",
|
||||
clientVersion: "test",
|
||||
});
|
||||
vi.mocked(getSurveys).mockRejectedValue(prismaError);
|
||||
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
DatabaseError
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(prismaError);
|
||||
});
|
||||
|
||||
test("should handle general errors", async () => {
|
||||
const generalError = new Error("Something went wrong");
|
||||
vi.mocked(getSurveys).mockRejectedValue(generalError);
|
||||
|
||||
await expect(getSyncSurveys(environmentId, contactId, contactAttributes, deviceType)).rejects.toThrow(
|
||||
generalError
|
||||
);
|
||||
});
|
||||
|
||||
test("should throw ResourceNotFoundError if resolved surveys are null after filtering", async () => {
|
||||
const segment = { id: "seg1", filters: [{}] } as TSegment; // Mock filter structure
|
||||
const surveys: TSurvey[] = [{ ...baseSurvey, id: "s1", segment }];
|
||||
vi.mocked(getSurveys).mockResolvedValue(surveys);
|
||||
vi.mocked(anySurveyHasFilters).mockReturnValue(true);
|
||||
vi.mocked(evaluateSegment).mockResolvedValue(false); // Ensure all surveys are filtered out
|
||||
|
||||
// This scenario is tricky to force directly as the code checks `if (!surveys)` before returning.
|
||||
// However, if `Promise.all` somehow resolved to null/undefined (highly unlikely), it should throw.
|
||||
// We can simulate this by mocking `Promise.all` if needed, but the current code structure makes this hard to test.
|
||||
// Let's assume the filter logic works correctly and test the intended path.
|
||||
const result = await getSyncSurveys(environmentId, contactId, contactAttributes, deviceType);
|
||||
expect(result).toEqual([]); // Expect empty array, not an error in this case.
|
||||
});
|
||||
});
|
||||
@@ -1,148 +0,0 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { logger } from "@formbricks/logger";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { DatabaseError, ResourceNotFoundError } from "@formbricks/types/errors";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { getProjectByEnvironmentId } from "@/lib/project/service";
|
||||
import { getSurveys } from "@/lib/survey/service";
|
||||
import { anySurveyHasFilters } from "@/lib/survey/utils";
|
||||
import { diffInDays } from "@/lib/utils/datetime";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
|
||||
|
||||
export const getSyncSurveys = reactCache(
|
||||
async (
|
||||
environmentId: string,
|
||||
contactId: string,
|
||||
contactAttributes: Record<string, string | number>,
|
||||
deviceType: "phone" | "desktop" = "desktop"
|
||||
): Promise<TSurvey[]> => {
|
||||
validateInputs([environmentId, ZId]);
|
||||
try {
|
||||
const project = await getProjectByEnvironmentId(environmentId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
let surveys = await getSurveys(environmentId);
|
||||
|
||||
// filtered surveys for running and web
|
||||
surveys = surveys.filter((survey) => survey.status === "inProgress" && survey.type === "app");
|
||||
|
||||
// if no surveys are left, return an empty array
|
||||
if (surveys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const displays = await prisma.display.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
|
||||
const responses = await prisma.response.findMany({
|
||||
where: {
|
||||
contactId,
|
||||
},
|
||||
});
|
||||
|
||||
// filter surveys that meet the displayOption criteria
|
||||
surveys = surveys.filter((survey) => {
|
||||
switch (survey.displayOption) {
|
||||
case "respondMultiple":
|
||||
return true;
|
||||
case "displayOnce":
|
||||
return displays.filter((display) => display.surveyId === survey.id).length === 0;
|
||||
case "displayMultiple":
|
||||
if (!responses) return true;
|
||||
else {
|
||||
return responses.filter((response) => response.surveyId === survey.id).length === 0;
|
||||
}
|
||||
case "displaySome":
|
||||
if (survey.displayLimit === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (responses && responses.filter((response) => response.surveyId === survey.id).length !== 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
|
||||
default:
|
||||
throw Error("Invalid displayOption");
|
||||
}
|
||||
});
|
||||
|
||||
const latestDisplay = displays[0];
|
||||
|
||||
// filter surveys that meet the recontactDays criteria
|
||||
surveys = surveys.filter((survey) => {
|
||||
if (!latestDisplay) {
|
||||
return true;
|
||||
} else if (survey.recontactDays !== null) {
|
||||
const lastDisplaySurvey = displays.filter((display) => display.surveyId === survey.id)[0];
|
||||
if (!lastDisplaySurvey) {
|
||||
return true;
|
||||
}
|
||||
return diffInDays(new Date(), new Date(lastDisplaySurvey.createdAt)) >= survey.recontactDays;
|
||||
} else if (project.recontactDays !== null) {
|
||||
return diffInDays(new Date(), new Date(latestDisplay.createdAt)) >= project.recontactDays;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
});
|
||||
|
||||
// if no surveys are left, return an empty array
|
||||
if (surveys.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// if no surveys have segment filters, return the surveys
|
||||
if (!anySurveyHasFilters(surveys)) {
|
||||
return surveys;
|
||||
}
|
||||
|
||||
// the surveys now have segment filters, so we need to evaluate them
|
||||
const surveyPromises = surveys.map(async (survey) => {
|
||||
const { segment } = survey;
|
||||
// if the survey has no segment, or the segment has no filters, we return the survey
|
||||
if (!segment || !segment.filters?.length) {
|
||||
return survey;
|
||||
}
|
||||
|
||||
// Evaluate the segment filters
|
||||
const result = await evaluateSegment(
|
||||
{
|
||||
attributes: contactAttributes ?? {},
|
||||
deviceType,
|
||||
environmentId,
|
||||
contactId,
|
||||
userId: String(contactAttributes.userId),
|
||||
},
|
||||
segment.filters
|
||||
);
|
||||
|
||||
return result ? survey : null;
|
||||
});
|
||||
|
||||
const resolvedSurveys = await Promise.all(surveyPromises);
|
||||
surveys = resolvedSurveys.filter((survey) => !!survey) as TSurvey[];
|
||||
|
||||
if (!surveys) {
|
||||
throw new ResourceNotFoundError("Survey", environmentId);
|
||||
}
|
||||
return surveys;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
logger.error(error);
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -1,245 +0,0 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TLanguage } from "@formbricks/types/project";
|
||||
import {
|
||||
TSurvey,
|
||||
TSurveyEnding,
|
||||
TSurveyQuestion,
|
||||
TSurveyQuestionTypeEnum,
|
||||
} from "@formbricks/types/surveys/types";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
import { replaceAttributeRecall } from "./utils";
|
||||
|
||||
vi.mock("@/lib/utils/recall", () => ({
|
||||
parseRecallInfo: vi.fn((text, attributes) => {
|
||||
const recallPattern = /recall:([a-zA-Z0-9_-]+)/;
|
||||
const match = text.match(recallPattern);
|
||||
if (match && match[1]) {
|
||||
const recallKey = match[1];
|
||||
const attributeValue = attributes[recallKey];
|
||||
if (attributeValue !== undefined) {
|
||||
return text.replace(recallPattern, `parsed-${attributeValue}`);
|
||||
}
|
||||
}
|
||||
return text; // Return original text if no match or attribute not found
|
||||
}),
|
||||
}));
|
||||
|
||||
const baseSurvey: TSurvey = {
|
||||
id: "survey1",
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
name: "Test Survey",
|
||||
environmentId: "env1",
|
||||
type: "app",
|
||||
status: "inProgress",
|
||||
questions: [],
|
||||
endings: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||
],
|
||||
triggers: [],
|
||||
recontactDays: null,
|
||||
displayLimit: null,
|
||||
singleUse: null,
|
||||
styling: null,
|
||||
surveyClosedMessage: null,
|
||||
hiddenFields: { enabled: false },
|
||||
variables: [],
|
||||
createdBy: null,
|
||||
isSingleResponsePerEmailEnabled: false,
|
||||
isVerifyEmailEnabled: false,
|
||||
projectOverwrites: null,
|
||||
showLanguageSwitch: false,
|
||||
isBackButtonHidden: false,
|
||||
followUps: [],
|
||||
recaptcha: { enabled: false, threshold: 0.5 },
|
||||
displayOption: "displayOnce",
|
||||
autoClose: null,
|
||||
delay: 0,
|
||||
displayPercentage: null,
|
||||
autoComplete: null,
|
||||
segment: null,
|
||||
pin: null,
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
const attributes: TAttributes = {
|
||||
name: "John Doe",
|
||||
email: "john.doe@example.com",
|
||||
plan: "premium",
|
||||
};
|
||||
|
||||
describe("replaceAttributeRecall", () => {
|
||||
test("should replace recall info in question headlines and subheaders", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Hello recall:name!" },
|
||||
subheader: { default: "Your email is recall:email" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||
expect(result.questions[0].subheader?.default).toBe("Your email is parsed-john.doe@example.com");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your email is recall:email", attributes);
|
||||
});
|
||||
|
||||
test("should replace recall info in welcome card headline", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome, recall:name!" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.welcomeCard.headline?.default).toBe("Welcome, parsed-John Doe!");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Welcome, recall:name!", attributes);
|
||||
});
|
||||
|
||||
test("should replace recall info in end screen headlines and subheaders", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
endings: [
|
||||
{
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you, recall:name!" },
|
||||
subheader: { default: "Your plan: recall:plan" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
buttonLink: "https://example.com",
|
||||
} as unknown as TSurveyEnding,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.endings[0].type).toBe("endScreen");
|
||||
if (result.endings[0].type === "endScreen") {
|
||||
expect(result.endings[0].headline?.default).toBe("Thank you, parsed-John Doe!");
|
||||
expect(result.endings[0].subheader?.default).toBe("Your plan: parsed-premium");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Thank you, recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your plan: recall:plan", attributes);
|
||||
}
|
||||
});
|
||||
|
||||
test("should handle multiple languages", () => {
|
||||
const surveyMultiLang: TSurvey = {
|
||||
...baseSurvey,
|
||||
languages: [
|
||||
{ language: { id: "lang1", code: "en" } as unknown as TLanguage, default: true, enabled: true },
|
||||
{ language: { id: "lang2", code: "es" } as unknown as TLanguage, default: false, enabled: true },
|
||||
],
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Hello recall:name!", es: "Hola recall:name!" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next", es: "Siguiente" },
|
||||
placeholder: { default: "Type here...", es: "Escribe aquí..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyMultiLang, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Hello parsed-John Doe!");
|
||||
expect(result.questions[0].headline.es).toBe("Hola parsed-John Doe!");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hello recall:name!", attributes);
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Hola recall:name!", attributes);
|
||||
});
|
||||
|
||||
test("should not replace if recall key is not in attributes", () => {
|
||||
const surveyWithRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Your company: recall:company" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
};
|
||||
|
||||
const result = replaceAttributeRecall(surveyWithRecall, attributes);
|
||||
expect(result.questions[0].headline.default).toBe("Your company: recall:company");
|
||||
expect(vi.mocked(parseRecallInfo)).toHaveBeenCalledWith("Your company: recall:company", attributes);
|
||||
});
|
||||
|
||||
test("should handle surveys with no recall information", async () => {
|
||||
const surveyNoRecall: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [
|
||||
{
|
||||
id: "q1",
|
||||
type: TSurveyQuestionTypeEnum.OpenText,
|
||||
headline: { default: "Just a regular question" },
|
||||
required: true,
|
||||
buttonLabel: { default: "Next" },
|
||||
placeholder: { default: "Type here..." },
|
||||
longAnswer: false,
|
||||
logic: [],
|
||||
} as unknown as TSurveyQuestion,
|
||||
],
|
||||
welcomeCard: {
|
||||
enabled: true,
|
||||
headline: { default: "Welcome!" },
|
||||
subheader: { default: "<p>Some content</p>" },
|
||||
buttonLabel: { default: "Start" },
|
||||
timeToFinish: false,
|
||||
showResponseCount: false,
|
||||
},
|
||||
endings: [
|
||||
{
|
||||
type: "endScreen",
|
||||
headline: { default: "Thank you!" },
|
||||
buttonLabel: { default: "Finish" },
|
||||
} as unknown as TSurveyEnding,
|
||||
],
|
||||
};
|
||||
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||
|
||||
const result = replaceAttributeRecall(surveyNoRecall, attributes);
|
||||
expect(result).toEqual(surveyNoRecall); // Should be unchanged
|
||||
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||
parseRecallInfoSpy.mockRestore();
|
||||
});
|
||||
|
||||
test("should handle surveys with empty questions, endings, or disabled welcome card", async () => {
|
||||
const surveyEmpty: TSurvey = {
|
||||
...baseSurvey,
|
||||
questions: [],
|
||||
endings: [],
|
||||
welcomeCard: { enabled: false } as TSurvey["welcomeCard"],
|
||||
};
|
||||
const parseRecallInfoSpy = vi.spyOn(await import("@/lib/utils/recall"), "parseRecallInfo");
|
||||
|
||||
const result = replaceAttributeRecall(surveyEmpty, attributes);
|
||||
expect(result).toEqual(surveyEmpty);
|
||||
expect(parseRecallInfoSpy).not.toHaveBeenCalled();
|
||||
parseRecallInfoSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -1,55 +0,0 @@
|
||||
import { TAttributes } from "@formbricks/types/attributes";
|
||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||
import { parseRecallInfo } from "@/lib/utils/recall";
|
||||
|
||||
export const replaceAttributeRecall = (survey: TSurvey, attributes: TAttributes): TSurvey => {
|
||||
const surveyTemp = structuredClone(survey);
|
||||
const languages = surveyTemp.languages
|
||||
.map((surveyLanguage) => {
|
||||
if (surveyLanguage.default) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (surveyLanguage.enabled) {
|
||||
return surveyLanguage.language.code;
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((language): language is string => language !== null);
|
||||
|
||||
surveyTemp.questions.forEach((question) => {
|
||||
languages.forEach((language) => {
|
||||
if (question.headline[language]?.includes("recall:")) {
|
||||
question.headline[language] = parseRecallInfo(question.headline[language], attributes);
|
||||
}
|
||||
if (question.subheader && question.subheader[language]?.includes("recall:")) {
|
||||
question.subheader[language] = parseRecallInfo(question.subheader[language], attributes);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (surveyTemp.welcomeCard.enabled && surveyTemp.welcomeCard.headline) {
|
||||
languages.forEach((language) => {
|
||||
if (surveyTemp.welcomeCard.headline && surveyTemp.welcomeCard.headline[language]?.includes("recall:")) {
|
||||
surveyTemp.welcomeCard.headline[language] = parseRecallInfo(
|
||||
surveyTemp.welcomeCard.headline[language],
|
||||
attributes
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
surveyTemp.endings.forEach((ending) => {
|
||||
if (ending.type === "endScreen") {
|
||||
languages.forEach((language) => {
|
||||
if (ending.headline && ending.headline[language]?.includes("recall:")) {
|
||||
ending.headline[language] = parseRecallInfo(ending.headline[language], attributes);
|
||||
if (ending.subheader && ending.subheader[language]?.includes("recall:")) {
|
||||
ending.subheader[language] = parseRecallInfo(ending.subheader[language], attributes);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return surveyTemp;
|
||||
};
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
TJsEnvironmentStateSurvey,
|
||||
} from "@formbricks/types/js";
|
||||
import { validateInputs } from "@/lib/utils/validate";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { transformPrismaSurvey } from "@/modules/survey/lib/utils";
|
||||
|
||||
/**
|
||||
@@ -177,14 +178,14 @@ export const getEnvironmentStateData = async (environmentId: string): Promise<En
|
||||
overlay: environmentData.project.overlay,
|
||||
placement: environmentData.project.placement,
|
||||
inAppSurveyBranding: environmentData.project.inAppSurveyBranding,
|
||||
styling: environmentData.project.styling,
|
||||
styling: resolveStorageUrlsInObject(environmentData.project.styling),
|
||||
},
|
||||
},
|
||||
organization: {
|
||||
id: environmentData.project.organization.id,
|
||||
billing: environmentData.project.organization.billing,
|
||||
},
|
||||
surveys: transformedSurveys,
|
||||
surveys: resolveStorageUrlsInObject(transformedSurveys),
|
||||
actionClasses: environmentData.actionClasses as TJsEnvironmentStateActionClass[],
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -44,13 +44,10 @@ const validateResponse = (
|
||||
...responseUpdateInput.data,
|
||||
};
|
||||
|
||||
const isFinished = responseUpdateInput.finished ?? false;
|
||||
|
||||
const validationErrors = validateResponseData(
|
||||
survey.blocks,
|
||||
mergedData,
|
||||
responseUpdateInput.language ?? response.language ?? "en",
|
||||
isFinished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ const validateResponse = (responseInputData: TResponseInput, survey: TSurvey) =>
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -6,140 +6,138 @@ export const GET = async (req: NextRequest) => {
|
||||
let brandColor = req.nextUrl.searchParams.get("brandColor");
|
||||
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
borderRadius: "0.75rem",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
alignItems: "center",
|
||||
backgroundColor: brandColor ? brandColor + "BF" : "#0000BFBF", // /75 opacity is approximately BF in hex
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "80%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3.25rem",
|
||||
position: "absolute",
|
||||
left: "3rem",
|
||||
top: "0.75rem",
|
||||
opacity: 0.2,
|
||||
transform: "rotate(356deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "84%",
|
||||
height: "60%",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "3rem",
|
||||
position: "absolute",
|
||||
top: "1.25rem",
|
||||
left: "3.25rem",
|
||||
borderWidth: "2px",
|
||||
opacity: 0.6,
|
||||
transform: "rotate(357deg)",
|
||||
}}></div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "85%",
|
||||
height: "67%",
|
||||
alignItems: "center",
|
||||
backgroundColor: "white",
|
||||
borderRadius: "0.75rem",
|
||||
marginTop: "2rem",
|
||||
position: "absolute",
|
||||
top: "2.3rem",
|
||||
left: "3.5rem",
|
||||
transform: "rotate(360deg)",
|
||||
}}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div style={{ display: "flex", flexDirection: "column", width: "100%" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
justifyContent: "space-between",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
}}>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
paddingLeft: "2rem",
|
||||
paddingRight: "2rem",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
}}>
|
||||
<h2
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
fontSize: "2rem",
|
||||
fontWeight: "700",
|
||||
letterSpacing: "-0.025em",
|
||||
color: "#0f172a",
|
||||
textAlign: "left",
|
||||
marginTop: "3.75rem",
|
||||
}}>
|
||||
{name}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end", marginRight: "2.5rem" }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
position: "absolute",
|
||||
right: "-0.5rem",
|
||||
marginTop: "0.5rem",
|
||||
}}>
|
||||
<div
|
||||
content=""
|
||||
style={{
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
opacity: 0.5,
|
||||
}}></div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
borderRadius: "1rem",
|
||||
boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
borderRadius: "0.75rem",
|
||||
border: "1px solid transparent",
|
||||
backgroundColor: brandColor ?? "#000",
|
||||
fontSize: "1.5rem",
|
||||
color: "white",
|
||||
height: "4.5rem",
|
||||
width: "9.5rem",
|
||||
}}>
|
||||
Begin!
|
||||
</div>
|
||||
Begin!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
</div>,
|
||||
{
|
||||
width: 800,
|
||||
height: 400,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { TIntegrationNotionConfigData, TIntegrationNotionInput } from "@formbricks/types/integration/notion";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import {
|
||||
ENCRYPTION_KEY,
|
||||
NOTION_OAUTH_CLIENT_ID,
|
||||
@@ -10,10 +10,17 @@ import {
|
||||
WEBAPP_URL,
|
||||
} from "@/lib/constants";
|
||||
import { symmetricEncrypt } from "@/lib/crypto";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
@@ -26,6 +33,13 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
|
||||
@@ -5,12 +5,19 @@ import {
|
||||
TIntegrationSlackCredential,
|
||||
} from "@formbricks/types/integration/slack";
|
||||
import { responses } from "@/app/lib/api/response";
|
||||
import { withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, WEBAPP_URL } from "@/lib/constants";
|
||||
import { TSessionAuthentication, withV1ApiWrapper } from "@/app/lib/api/with-api-logging";
|
||||
import { SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI, WEBAPP_URL } from "@/lib/constants";
|
||||
import { hasUserEnvironmentAccess } from "@/lib/environment/auth";
|
||||
import { createOrUpdateIntegration, getIntegrationByType } from "@/lib/integration/service";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
handler: async ({ req }: { req: NextRequest }) => {
|
||||
handler: async ({
|
||||
req,
|
||||
authentication,
|
||||
}: {
|
||||
req: NextRequest;
|
||||
authentication: NonNullable<TSessionAuthentication>;
|
||||
}) => {
|
||||
const url = req.url;
|
||||
const queryParams = new URLSearchParams(url.split("?")[1]); // Split the URL and get the query parameters
|
||||
const environmentId = queryParams.get("state"); // Get the value of the 'state' parameter
|
||||
@@ -23,6 +30,13 @@ export const GET = withV1ApiWrapper({
|
||||
};
|
||||
}
|
||||
|
||||
const canUserAccessEnvironment = await hasUserEnvironmentAccess(authentication.user.id, environmentId);
|
||||
if (!canUserAccessEnvironment) {
|
||||
return {
|
||||
response: responses.unauthorizedResponse(),
|
||||
};
|
||||
}
|
||||
|
||||
if (code && typeof code !== "string") {
|
||||
return {
|
||||
response: responses.badRequestResponse("`code` must be a string"),
|
||||
@@ -42,6 +56,7 @@ export const GET = withV1ApiWrapper({
|
||||
code,
|
||||
client_id: SLACK_CLIENT_ID,
|
||||
client_secret: SLACK_CLIENT_SECRET,
|
||||
redirect_uri: SLACK_REDIRECT_URI,
|
||||
};
|
||||
const formBody: string[] = [];
|
||||
for (const property in formData) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import { deleteResponse, getResponse } from "@/lib/response/service";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import { updateResponseWithQuotaEvaluation } from "./lib/response";
|
||||
|
||||
async function fetchAndAuthorizeResponse(
|
||||
@@ -57,7 +57,10 @@ export const GET = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.response),
|
||||
response: responses.successResponse({
|
||||
...result.response,
|
||||
data: resolveStorageUrlsInObject(result.response.data),
|
||||
}),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -146,7 +149,6 @@ export const PUT = withV1ApiWrapper({
|
||||
result.survey.blocks,
|
||||
responseUpdate.data,
|
||||
responseUpdate.language ?? "en",
|
||||
responseUpdate.finished,
|
||||
result.survey.questions
|
||||
);
|
||||
|
||||
@@ -190,7 +192,7 @@ export const PUT = withV1ApiWrapper({
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updated),
|
||||
response: responses.successResponse({ ...updated, data: resolveStorageUrlsInObject(updated.data) }),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { sendToPipeline } from "@/app/lib/pipelines";
|
||||
import { getSurvey } from "@/lib/survey/service";
|
||||
import { formatValidationErrorsForV1Api, validateResponseData } from "@/modules/api/lib/validation";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { validateFileUploads } from "@/modules/storage/utils";
|
||||
import { resolveStorageUrlsInObject, validateFileUploads } from "@/modules/storage/utils";
|
||||
import {
|
||||
createResponseWithQuotaEvaluation,
|
||||
getResponses,
|
||||
@@ -54,7 +54,9 @@ export const GET = withV1ApiWrapper({
|
||||
allResponses.push(...environmentResponses);
|
||||
}
|
||||
return {
|
||||
response: responses.successResponse(allResponses),
|
||||
response: responses.successResponse(
|
||||
allResponses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }))
|
||||
),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
@@ -155,7 +157,6 @@ export const POST = withV1ApiWrapper({
|
||||
surveyResult.survey.blocks,
|
||||
responseInput.data,
|
||||
responseInput.language ?? "en",
|
||||
responseInput.finished,
|
||||
surveyResult.survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { getSurvey, updateSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
|
||||
const fetchAndAuthorizeSurvey = async (
|
||||
surveyId: string,
|
||||
@@ -58,16 +59,18 @@ export const GET = withV1ApiWrapper({
|
||||
|
||||
if (shouldTransformToQuestions) {
|
||||
return {
|
||||
response: responses.successResponse({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
}),
|
||||
response: responses.successResponse(
|
||||
resolveStorageUrlsInObject({
|
||||
...result.survey,
|
||||
questions: transformBlocksToQuestions(result.survey.blocks, result.survey.endings),
|
||||
blocks: [],
|
||||
})
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(result.survey),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(result.survey)),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
@@ -202,12 +205,12 @@ export const PUT = withV1ApiWrapper({
|
||||
};
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveyWithQuestions),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveyWithQuestions)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
response: responses.successResponse(updatedSurvey),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(updatedSurvey)),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { TApiAuditLog, TApiKeyAuthentication, withV1ApiWrapper } from "@/app/lib
|
||||
import { getOrganizationByEnvironmentId } from "@/lib/organization/service";
|
||||
import { createSurvey } from "@/lib/survey/service";
|
||||
import { hasPermission } from "@/modules/organization/settings/api-keys/lib/utils";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getSurveys } from "./lib/surveys";
|
||||
|
||||
export const GET = withV1ApiWrapper({
|
||||
@@ -55,7 +56,7 @@ export const GET = withV1ApiWrapper({
|
||||
});
|
||||
|
||||
return {
|
||||
response: responses.successResponse(surveysWithQuestions),
|
||||
response: responses.successResponse(resolveStorageUrlsInObject(surveysWithQuestions)),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof DatabaseError) {
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
OPTIONS,
|
||||
PUT,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/contacts/[userId]/attributes/route";
|
||||
|
||||
export { OPTIONS, PUT };
|
||||
@@ -1,6 +0,0 @@
|
||||
import {
|
||||
GET,
|
||||
OPTIONS,
|
||||
} from "@/modules/ee/contacts/api/v1/client/[environmentId]/identify/contacts/[userId]/route";
|
||||
|
||||
export { GET, OPTIONS };
|
||||
@@ -112,7 +112,6 @@ export const POST = async (request: Request, context: Context): Promise<Response
|
||||
survey.blocks,
|
||||
responseInputData.data,
|
||||
responseInputData.language ?? "en",
|
||||
responseInputData.finished,
|
||||
survey.questions
|
||||
);
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ vi.mock("@/app/middleware/endpoint-validator", async () => {
|
||||
isClientSideApiRoute: vi.fn().mockReturnValue({ isClientSideApi: false, isRateLimited: true }),
|
||||
isManagementApiRoute: vi.fn().mockReturnValue({ isManagementApi: false, authenticationMethod: "apiKey" }),
|
||||
isIntegrationRoute: vi.fn().mockReturnValue(false),
|
||||
isSyncWithUserIdentificationEndpoint: vi.fn().mockReturnValue(null),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -82,7 +81,6 @@ vi.mock("@/modules/core/rate-limit/rate-limit-configs", () => ({
|
||||
api: {
|
||||
client: { windowMs: 60000, max: 100 },
|
||||
v1: { windowMs: 60000, max: 1000 },
|
||||
syncUserIdentification: { windowMs: 60000, max: 50 },
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -133,13 +131,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on error response with API key authentication", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -187,13 +183,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log Sentry if not 500", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -235,13 +229,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("logs and audits on thrown error", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -293,13 +285,11 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("does not log on success response but still audits", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -349,13 +339,11 @@ describe("withV1ApiWrapper", () => {
|
||||
REDIS_URL: "redis://localhost:6379",
|
||||
}));
|
||||
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -378,9 +366,8 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("handles client-side API routes without authentication", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
|
||||
@@ -412,9 +399,8 @@ describe("withV1ApiWrapper", () => {
|
||||
});
|
||||
|
||||
test("returns authentication error for non-client routes without auth", async () => {
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
@@ -437,9 +423,8 @@ describe("withV1ApiWrapper", () => {
|
||||
|
||||
test("handles rate limiting errors", async () => {
|
||||
const { applyRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
@@ -463,53 +448,12 @@ describe("withV1ApiWrapper", () => {
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("handles sync user identification rate limiting", async () => {
|
||||
const { applyRateLimit, applyIPRateLimit } = await import("@/modules/core/rate-limit/helpers");
|
||||
const {
|
||||
isClientSideApiRoute,
|
||||
isManagementApiRoute,
|
||||
isIntegrationRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} = await import("@/app/middleware/endpoint-validator");
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: true, isRateLimited: true });
|
||||
vi.mocked(isManagementApiRoute).mockReturnValue({
|
||||
isManagementApi: false,
|
||||
authenticationMethod: AuthenticationMethod.None,
|
||||
});
|
||||
vi.mocked(isIntegrationRoute).mockReturnValue(false);
|
||||
vi.mocked(isSyncWithUserIdentificationEndpoint).mockReturnValue({
|
||||
userId: "user-123",
|
||||
environmentId: "env-123",
|
||||
});
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(null);
|
||||
vi.mocked(applyIPRateLimit).mockResolvedValue(undefined);
|
||||
const rateLimitError = new Error("Sync rate limit exceeded");
|
||||
rateLimitError.message = "Sync rate limit exceeded";
|
||||
vi.mocked(applyRateLimit).mockRejectedValue(rateLimitError);
|
||||
|
||||
const handler = vi.fn();
|
||||
const req = createMockRequest({ url: "/api/v1/client/env-123/app/sync/user-123" });
|
||||
const { withV1ApiWrapper } = await import("./with-api-logging");
|
||||
const wrapped = withV1ApiWrapper({ handler });
|
||||
const res = await wrapped(req, undefined);
|
||||
|
||||
expect(res.status).toBe(429);
|
||||
expect(applyRateLimit).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ windowMs: 60000, max: 50 }),
|
||||
"user-123"
|
||||
);
|
||||
});
|
||||
|
||||
test("skips audit log creation when no action/targetType provided", async () => {
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } = (await import(
|
||||
"@/modules/ee/audit-logs/lib/handler"
|
||||
)) as unknown as { queueAuditEvent: Mock };
|
||||
const { queueAuditEvent: mockedQueueAuditEvent } =
|
||||
(await import("@/modules/ee/audit-logs/lib/handler")) as unknown as { queueAuditEvent: Mock };
|
||||
const { authenticateRequest } = await import("@/app/api/v1/auth");
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } = await import(
|
||||
"@/app/middleware/endpoint-validator"
|
||||
);
|
||||
const { isClientSideApiRoute, isManagementApiRoute, isIntegrationRoute } =
|
||||
await import("@/app/middleware/endpoint-validator");
|
||||
|
||||
vi.mocked(authenticateRequest).mockResolvedValue(mockApiAuthentication);
|
||||
vi.mocked(isClientSideApiRoute).mockReturnValue({ isClientSideApi: false, isRateLimited: true });
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
isClientSideApiRoute,
|
||||
isIntegrationRoute,
|
||||
isManagementApiRoute,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} from "@/app/middleware/endpoint-validator";
|
||||
import { AUDIT_LOG_ENABLED, IS_PRODUCTION, SENTRY_DSN } from "@/lib/constants";
|
||||
import { authOptions } from "@/modules/auth/lib/authOptions";
|
||||
@@ -48,23 +47,16 @@ enum ApiV1RouteTypeEnum {
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply client-side API rate limiting (IP-based or sync-specific)
|
||||
* Apply client-side API rate limiting (IP-based)
|
||||
*/
|
||||
const applyClientRateLimit = async (url: string, customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
||||
const syncEndpoint = isSyncWithUserIdentificationEndpoint(url);
|
||||
if (syncEndpoint) {
|
||||
const syncRateLimitConfig = rateLimitConfigs.api.syncUserIdentification;
|
||||
await applyRateLimit(syncRateLimitConfig, syncEndpoint.userId);
|
||||
} else {
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
}
|
||||
const applyClientRateLimit = async (customRateLimitConfig?: TRateLimitConfig): Promise<void> => {
|
||||
await applyIPRateLimit(customRateLimitConfig ?? rateLimitConfigs.api.client);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle rate limiting based on authentication and API type
|
||||
*/
|
||||
const handleRateLimiting = async (
|
||||
url: string,
|
||||
authentication: TApiV1Authentication,
|
||||
routeType: ApiV1RouteTypeEnum,
|
||||
customRateLimitConfig?: TRateLimitConfig
|
||||
@@ -84,7 +76,7 @@ const handleRateLimiting = async (
|
||||
}
|
||||
|
||||
if (routeType === ApiV1RouteTypeEnum.Client) {
|
||||
await applyClientRateLimit(url, customRateLimitConfig);
|
||||
await applyClientRateLimit(customRateLimitConfig);
|
||||
}
|
||||
} catch (error) {
|
||||
return responses.tooManyRequestsResponse(error.message);
|
||||
@@ -255,7 +247,6 @@ const getRouteType = (
|
||||
* Features:
|
||||
* - Performs authentication once and passes result to handler
|
||||
* - Applies API key-based rate limiting with differentiated limits for client vs management APIs
|
||||
* - Includes additional sync user identification rate limiting for client-side sync endpoints
|
||||
* - Sets userId and organizationId in audit log automatically when audit logging is enabled
|
||||
* - System and Sentry logs are always called for non-success responses
|
||||
* - Uses function overloads to provide type safety without requiring type guards
|
||||
@@ -328,12 +319,7 @@ export const withV1ApiWrapper: {
|
||||
|
||||
// === Rate Limiting ===
|
||||
if (isRateLimited) {
|
||||
const rateLimitResponse = await handleRateLimiting(
|
||||
req.nextUrl.pathname,
|
||||
authentication,
|
||||
routeType,
|
||||
customRateLimitConfig
|
||||
);
|
||||
const rateLimitResponse = await handleRateLimiting(authentication, routeType, customRateLimitConfig);
|
||||
if (rateLimitResponse) return rateLimitResponse;
|
||||
}
|
||||
|
||||
|
||||
@@ -4854,6 +4854,17 @@ export const previewSurvey = (projectName: string, t: TFunction): TSurvey => {
|
||||
}),
|
||||
isDraft: true,
|
||||
},
|
||||
{
|
||||
...buildOpenTextElement({
|
||||
id: "preview-open-text-01",
|
||||
headline: t("templates.preview_survey_question_open_text_headline"),
|
||||
subheader: t("templates.preview_survey_question_open_text_subheader"),
|
||||
placeholder: t("templates.preview_survey_question_open_text_placeholder"),
|
||||
inputType: "text",
|
||||
required: false,
|
||||
}),
|
||||
isDraft: true,
|
||||
},
|
||||
],
|
||||
buttonLabel: createI18nString(t("templates.next"), []),
|
||||
backButtonLabel: createI18nString(t("templates.preview_survey_question_2_back_button_label"), []),
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
isManagementApiRoute,
|
||||
isPublicDomainRoute,
|
||||
isRouteAllowedForDomain,
|
||||
isSyncWithUserIdentificationEndpoint,
|
||||
} from "./endpoint-validator";
|
||||
|
||||
describe("endpoint-validator", () => {
|
||||
@@ -258,6 +257,7 @@ describe("endpoint-validator", () => {
|
||||
expect(isAuthProtectedRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/s/survey123")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/p/pretty-url")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/health")).toBe(false);
|
||||
});
|
||||
@@ -270,58 +270,6 @@ describe("endpoint-validator", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSyncWithUserIdentificationEndpoint", () => {
|
||||
test("should return environmentId and userId for valid sync URLs", () => {
|
||||
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||
expect(result1).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
|
||||
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/abc-123/app/sync/xyz-789");
|
||||
expect(result2).toEqual({
|
||||
environmentId: "abc-123",
|
||||
userId: "xyz-789",
|
||||
});
|
||||
|
||||
const result3 = isSyncWithUserIdentificationEndpoint(
|
||||
"/api/v1/client/env_123_test/app/sync/user_456_test"
|
||||
);
|
||||
expect(result3).toEqual({
|
||||
environmentId: "env_123_test",
|
||||
userId: "user_456_test",
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle optional trailing slash", () => {
|
||||
// Test both with and without trailing slash
|
||||
const result1 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456");
|
||||
expect(result1).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
|
||||
const result2 = isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/user456/");
|
||||
expect(result2).toEqual({
|
||||
environmentId: "env123",
|
||||
userId: "user456",
|
||||
});
|
||||
});
|
||||
|
||||
test("should return false for invalid sync URLs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/something")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/other/user456")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v2/client/env123/app/sync/user456")).toBe(false); // only v1 supported
|
||||
});
|
||||
|
||||
test("should handle empty or malformed IDs", () => {
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client//app/sync/user456")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("/api/v1/client/env123/app/sync/")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isPublicDomainRoute", () => {
|
||||
test("should return true for health endpoint", () => {
|
||||
expect(isPublicDomainRoute("/health")).toBe(true);
|
||||
@@ -366,6 +314,19 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/contact/token")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for pretty URL survey routes", () => {
|
||||
expect(isPublicDomainRoute("/p/pretty123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty-name-with-dashes")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/survey_id_with_underscores")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/abc123def456")).toBe(true);
|
||||
});
|
||||
|
||||
test("should return false for malformed pretty URL survey routes", () => {
|
||||
expect(isPublicDomainRoute("/p/")).toBe(false);
|
||||
expect(isPublicDomainRoute("/p")).toBe(false);
|
||||
expect(isPublicDomainRoute("/pretty/123")).toBe(false);
|
||||
});
|
||||
|
||||
test("should return true for client API routes", () => {
|
||||
expect(isPublicDomainRoute("/api/v1/client/something")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v2/client/other")).toBe(true);
|
||||
@@ -428,6 +389,8 @@ describe("endpoint-validator", () => {
|
||||
expect(isAdminDomainRoute("/s/survey-id-with-dashes")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/jwt-token")).toBe(false);
|
||||
expect(isAdminDomainRoute("/c/very-long-jwt-token-123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/p/pretty123")).toBe(false);
|
||||
expect(isAdminDomainRoute("/p/pretty-name-with-dashes")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v1/client/test")).toBe(false);
|
||||
expect(isAdminDomainRoute("/api/v2/client/other")).toBe(false);
|
||||
});
|
||||
@@ -443,6 +406,7 @@ describe("endpoint-validator", () => {
|
||||
test("should allow public routes on public domain", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/health", true)).toBe(true);
|
||||
@@ -479,6 +443,8 @@ describe("endpoint-validator", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey-id-with-dashes", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/jwt-token", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/c/very-long-jwt-token-123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/p/pretty-name-with-dashes", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v1/client/test", false)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/api/v2/client/other", false)).toBe(false);
|
||||
});
|
||||
@@ -493,6 +459,8 @@ describe("endpoint-validator", () => {
|
||||
test("should handle paths with query parameters and fragments", () => {
|
||||
expect(isRouteAllowedForDomain("/s/survey123?param=value", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/s/survey123#section", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123?param=value", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/p/pretty123#section", true)).toBe(true);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", true)).toBe(false);
|
||||
expect(isRouteAllowedForDomain("/environments/123?tab=settings", false)).toBe(true);
|
||||
});
|
||||
@@ -503,6 +471,7 @@ describe("endpoint-validator", () => {
|
||||
describe("URL parsing edge cases", () => {
|
||||
test("should handle paths with query parameters", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123?param=value&other=test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123?param=value&other=test")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test?query=data")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123?tab=settings")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/environments/123?tab=overview")).toBe(true);
|
||||
@@ -511,12 +480,14 @@ describe("endpoint-validator", () => {
|
||||
test("should handle paths with fragments", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt-token#top")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123#section")).toBe(true);
|
||||
expect(isPublicDomainRoute("/environments/123#overview")).toBe(false);
|
||||
expect(isAuthProtectedRoute("/organizations/456#settings")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle trailing slashes", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/")).toBe(true);
|
||||
expect(isPublicDomainRoute("/api/v1/client/test/")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test/")).toEqual({
|
||||
isManagementApi: true,
|
||||
@@ -531,6 +502,9 @@ describe("endpoint-validator", () => {
|
||||
expect(isPublicDomainRoute("/s/survey123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/embed")).toBe(true);
|
||||
expect(isPublicDomainRoute("/s/survey123/thank-you")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/preview")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/embed")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty123/thank-you")).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle nested client API routes", () => {
|
||||
@@ -582,12 +556,7 @@ describe("endpoint-validator", () => {
|
||||
test("should handle special characters in survey IDs", () => {
|
||||
expect(isPublicDomainRoute("/s/survey-123_test.v2")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt.token.with.dots")).toBe(true);
|
||||
expect(
|
||||
isSyncWithUserIdentificationEndpoint("/api/v1/client/env-123_test/app/sync/user-456_test")
|
||||
).toEqual({
|
||||
environmentId: "env-123_test",
|
||||
userId: "user-456_test",
|
||||
});
|
||||
expect(isPublicDomainRoute("/p/pretty-123_test.v2")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -595,6 +564,7 @@ describe("endpoint-validator", () => {
|
||||
test("should properly validate malicious or injection-like URLs", () => {
|
||||
// SQL injection-like attempts
|
||||
expect(isPublicDomainRoute("/s/'; DROP TABLE users; --")).toBe(true); // Still valid survey ID format
|
||||
expect(isPublicDomainRoute("/p/'; DROP TABLE users; --")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/'; DROP TABLE users; --")).toEqual({
|
||||
isManagementApi: true,
|
||||
authenticationMethod: AuthenticationMethod.ApiKey,
|
||||
@@ -602,10 +572,12 @@ describe("endpoint-validator", () => {
|
||||
|
||||
// Path traversal attempts
|
||||
expect(isPublicDomainRoute("/s/../../../etc/passwd")).toBe(true); // Still matches pattern
|
||||
expect(isPublicDomainRoute("/p/../../../etc/passwd")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments/../../../etc/passwd")).toBe(true);
|
||||
|
||||
// XSS-like attempts
|
||||
expect(isPublicDomainRoute("/s/<script>alert('xss')</script>")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/<script>alert('xss')</script>")).toBe(true);
|
||||
expect(isClientSideApiRoute("/api/v1/client/<script>alert('xss')</script>")).toEqual({
|
||||
isClientSideApi: true,
|
||||
isRateLimited: true,
|
||||
@@ -615,6 +587,7 @@ describe("endpoint-validator", () => {
|
||||
test("should handle URL encoding", () => {
|
||||
expect(isPublicDomainRoute("/s/survey%20123")).toBe(true);
|
||||
expect(isPublicDomainRoute("/c/jwt%2Etoken")).toBe(true);
|
||||
expect(isPublicDomainRoute("/p/pretty%20123")).toBe(true);
|
||||
expect(isAuthProtectedRoute("/environments%2F123")).toBe(true);
|
||||
expect(isManagementApiRoute("/api/v1/management/test%20route")).toEqual({
|
||||
isManagementApi: true,
|
||||
@@ -628,15 +601,6 @@ describe("endpoint-validator", () => {
|
||||
const longSurveyId = "a".repeat(1000);
|
||||
const longPath = `s/${longSurveyId}`;
|
||||
expect(isPublicDomainRoute(`/${longPath}`)).toBe(true);
|
||||
|
||||
const longEnvironmentId = "env" + "a".repeat(1000);
|
||||
const longUserId = "user" + "b".repeat(1000);
|
||||
expect(
|
||||
isSyncWithUserIdentificationEndpoint(`/api/v1/client/${longEnvironmentId}/app/sync/${longUserId}`)
|
||||
).toEqual({
|
||||
environmentId: longEnvironmentId,
|
||||
userId: longUserId,
|
||||
});
|
||||
});
|
||||
|
||||
test("should handle empty and minimal inputs", () => {
|
||||
@@ -651,7 +615,6 @@ describe("endpoint-validator", () => {
|
||||
});
|
||||
expect(isIntegrationRoute("")).toBe(false);
|
||||
expect(isAuthProtectedRoute("")).toBe(false);
|
||||
expect(isSyncWithUserIdentificationEndpoint("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -660,6 +623,7 @@ describe("endpoint-validator", () => {
|
||||
// These should not match due to case sensitivity
|
||||
expect(isPublicDomainRoute("/S/survey123")).toBe(false);
|
||||
expect(isPublicDomainRoute("/C/jwt-token")).toBe(false);
|
||||
expect(isPublicDomainRoute("/P/pretty123")).toBe(false);
|
||||
expect(isClientSideApiRoute("/API/V1/CLIENT/test")).toEqual({
|
||||
isClientSideApi: false,
|
||||
isRateLimited: true,
|
||||
|
||||
@@ -43,14 +43,6 @@ export const isAuthProtectedRoute = (url: string): boolean => {
|
||||
return protectedRoutes.some((route) => url.startsWith(route));
|
||||
};
|
||||
|
||||
export const isSyncWithUserIdentificationEndpoint = (
|
||||
url: string
|
||||
): { environmentId: string; userId: string } | false => {
|
||||
const regex = /\/api\/v1\/client\/(?<environmentId>[^/]+)\/app\/sync\/(?<userId>[^/]+)/;
|
||||
const match = url.match(regex);
|
||||
return match ? { environmentId: match.groups!.environmentId, userId: match.groups!.userId } : false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if the route should be accessible on the public domain (PUBLIC_URL)
|
||||
* Uses whitelist approach - only explicitly allowed routes are accessible
|
||||
|
||||
@@ -7,6 +7,7 @@ const PUBLIC_ROUTES = {
|
||||
SURVEY_ROUTES: [
|
||||
/^\/s\/[^/]+/, // /s/[surveyId] - survey pages
|
||||
/^\/c\/[^/]+/, // /c/[jwt] - contact survey pages
|
||||
/^\/p\/[^/]+/, // /p/[prettyUrl] - pretty URL pages
|
||||
],
|
||||
|
||||
// API routes accessible from public domain
|
||||
|
||||
@@ -150,7 +150,9 @@ checksums:
|
||||
common/copy_link: 57a37acfe6d7ed71d00fbbc8079fbb35
|
||||
common/count_attributes: 042fba9baffef5afe2c24f13d4f50697
|
||||
common/count_contacts: b1c413a4b06961b71b6aeee95d6775d7
|
||||
common/count_members: 8cabb9805075f20e3977b919b3b2fdc5
|
||||
common/count_responses: 690118a456c01c5b4d437ae82b50b131
|
||||
common/count_selections: c0f581d21468af2f46dad171921f71ba
|
||||
common/create_new_organization: 51dae7b33143686ee218abf5bea764a5
|
||||
common/create_segment: 9d8291cd4d778b53b73bbc84fd91c181
|
||||
common/create_survey: 1cfbba08d34876566d84b2960054a987
|
||||
@@ -161,8 +163,10 @@ checksums:
|
||||
common/customer_success: 2b0c99a5f57e1d16cf0a998f9bb116c4
|
||||
common/dark_overlay: 173e84b526414dbc70dbf9737e443b60
|
||||
common/date: 56f41c5d30a76295bb087b20b7bee4c3
|
||||
common/days: c95fe8aedde21a0b5653dbd0b3c58b48
|
||||
common/default: d9c6dc5c412fe94143dfd1d332ec81d4
|
||||
common/delete: 8bcf303dd10a645b5baacb02b47d72c9
|
||||
common/delete_what: 718ddfcc1dec7f3e8b67856fba838267
|
||||
common/description: e17686a22ffad04cc7bb70524ed4478b
|
||||
common/dev_env: e650911d5e19ba256358e0cda154c005
|
||||
common/development: 85211dbb918bda7a6e87649dcfc1b17a
|
||||
@@ -190,13 +194,16 @@ checksums:
|
||||
common/error: 3c95bcb32c2104b99a46f5b3dd015248
|
||||
common/error_component_description: fa9eee04f864c3fe6e6681f716caa015
|
||||
common/error_component_title: ae68fa341a143aaa13a5ea30dd57a63e
|
||||
common/error_loading_data: aaeffbfe4a2c2145442a57de524494be
|
||||
common/error_rate_limit_description: 37791a33a947204662ee9c6544e90f51
|
||||
common/error_rate_limit_title: 23ac9419e267e610e1bfd38e1dc35dc0
|
||||
common/expand_rows: b6e06327cb8718dfd6651720843e4dad
|
||||
common/failed_to_copy_to_clipboard: de836a7d628d36c832809252f188f784
|
||||
common/failed_to_load_organizations: 512808a2b674c7c28bca73f8f91fd87e
|
||||
common/failed_to_load_workspaces: 6ee3448097394517dc605074cd4e6ea4
|
||||
common/filter: 626325a05e4c8800f7ede7012b0cadaf
|
||||
common/finish: ffa7a10f71182b48fefed7135bee24fa
|
||||
common/first_name: cf040a5d6a9fd696be400380cc99f54b
|
||||
common/follow_these: 3a730b242bb17a3f95e01bf0dae86885
|
||||
common/formbricks_version: d9967c797f3e49ca0cae78bc0ebd19cb
|
||||
common/full_name: f45991923345e8322c9ff8cd6b7e2b16
|
||||
@@ -209,6 +216,7 @@ checksums:
|
||||
common/hidden_field: 3ed5c58d0ed359e558cdf7bd33606d2d
|
||||
common/hidden_fields: 3de6cfd308293a826cb8679fd1d49972
|
||||
common/hide_column: 23ce94db148f2d8e4a0923defead6cf1
|
||||
common/id: c8886d38aeea2ed5f785aba4fc96784b
|
||||
common/image: 048ba7a239de0fbd883ade8558415830
|
||||
common/images: 9305827c28694866f49db42b4c51831f
|
||||
common/import: 348b8ab981de5b7f1fca6d7302263bbd
|
||||
@@ -226,6 +234,7 @@ checksums:
|
||||
common/key: 3d1065ab98a1c2f1210507fd5c7bf515
|
||||
common/label: a5c71bf158481233f8215dbd38cc196b
|
||||
common/language: 277fd1a41cc237a437cd1d5e4a80463b
|
||||
common/last_name: 2c9a7de7738ca007ba9023c385149c26
|
||||
common/learn_more: e598091d132f890c37a6d4ed94f6d794
|
||||
common/license_expired: 7af13535e320e4197989472c01387d2c
|
||||
common/light_overlay: 0499907ea7b8405f4267b117998b5a78
|
||||
@@ -248,9 +257,11 @@ checksums:
|
||||
common/mobile_overlay_app_works_best_on_desktop: 4509f7bfbb4edbd42e534042d6cb7e72
|
||||
common/mobile_overlay_surveys_look_good: d85169e86077738b9837647bf6d1c7d2
|
||||
common/mobile_overlay_title: 42f52259b7527989fb3a3240f5352a8b
|
||||
common/months: da74749fbe80394fa0f72973d7b0964a
|
||||
common/move_down: 4f4de55743043355ad4a839aff2c48ff
|
||||
common/move_up: 69f25b205c677abdb26cbb69d97cd10b
|
||||
common/multiple_languages: 7d8ddd4b40d32fcd7bd6f7bac6485b1f
|
||||
common/my_product: ad022177062f9ef6e9acf33b13e889aa
|
||||
common/name: 9368b5a047572b6051f334af5aa76819
|
||||
common/new: 126d036fae5fb6b629728ecb97e6195b
|
||||
common/new_version_available: 399ddfc4232712e18ddab2587356b3dc
|
||||
@@ -366,6 +377,7 @@ checksums:
|
||||
common/status: 4e1fcce15854d824919b4a582c697c90
|
||||
common/step_by_step_manual: 2894a07952a4fd11d98d5d8f1088690c
|
||||
common/storage_not_configured: b0c3e339f6d71f23fdd189e7bcb076f6
|
||||
common/string: 4ddccc1974775ed7357f9beaf9361cec
|
||||
common/styling: 240fc91eb03c52d46b137f82e7aec2a1
|
||||
common/submit: 7c91ef5f747eea9f77a9c4f23e19fb2e
|
||||
common/summary: 13eb7b8a239fb4702dfdaee69100a220
|
||||
@@ -398,6 +410,7 @@ checksums:
|
||||
common/top_right: 241f95c923846911aaf13af6109333e5
|
||||
common/try_again: 33dd8820e743e35a66e6977f69e9d3b5
|
||||
common/type: f04471a7ddac844b9ad145eb9911ef75
|
||||
common/unknown_survey: dd8f6985e17ccf19fac1776e18b2c498
|
||||
common/unlock_more_workspaces_with_a_higher_plan: fe1590075b855bb4306c9388b65143b0
|
||||
common/update: 079fc039262fd31b10532929685c2d1b
|
||||
common/updated: 8aa8ff2dc2977ca4b269e80a513100b4
|
||||
@@ -421,6 +434,7 @@ checksums:
|
||||
common/website_and_app_connection: 60fea5cff5bddb4db3c8a1b0a2f9ec63
|
||||
common/website_app_survey: 258579927ed3955dcc8e1cbd7f0df17f
|
||||
common/website_survey: 17513d25a07b6361768a15ec622b021b
|
||||
common/weeks: 545de30df4f44d3f6d1d344af6a10815
|
||||
common/welcome_card: 76081ebd5b2e35da9b0f080323704ae7
|
||||
common/workspace_configuration: d0a5812d6a97d7724d565b1017c34387
|
||||
common/workspace_created_successfully: bf401ae83da954f1db48724e2a8e40f1
|
||||
@@ -431,6 +445,7 @@ checksums:
|
||||
common/workspace_not_found: 038fb0aaf3570610f4377b9eaed13752
|
||||
common/workspace_permission_not_found: e94bdff8af51175c5767714f82bb4833
|
||||
common/workspaces: 8ba082a84aa35cf851af1cf874b853e2
|
||||
common/years: eb4f5fdd2b320bf13e200fd6a6c1abff
|
||||
common/you: db2a4a796b70cc1430d1b21f6ffb6dcb
|
||||
common/you_are_downgraded_to_the_community_edition: e3ae56502ff787109cae0997519f628e
|
||||
common/you_are_not_authorized_to_perform_this_action: 1b3255ab740582ddff016a399f8bf302
|
||||
@@ -593,28 +608,45 @@ checksums:
|
||||
environments/contacts/attribute_updated_successfully: 0e64422156c29940cd4dab2f9d1f40b2
|
||||
environments/contacts/attribute_value: 34b0eaa85808b15cbc4be94c64d0146b
|
||||
environments/contacts/attribute_value_placeholder: 90fb17015de807031304d7a650a6cb8c
|
||||
environments/contacts/attributes_msg_attribute_limit_exceeded: a6c430860f307f9cc90c449f96a1284f
|
||||
environments/contacts/attributes_msg_attribute_type_validation_error: ed177ce83bd174ed6be7e889664f93a1
|
||||
environments/contacts/attributes_msg_email_already_exists: a3ea1265e3db885f53d0e589aecf6260
|
||||
environments/contacts/attributes_msg_email_or_userid_required: 3be0e745cd3500c9a23bad2e25ad3147
|
||||
environments/contacts/attributes_msg_new_attribute_created: c4c7b27523058f43b70411d7aa6510e5
|
||||
environments/contacts/attributes_msg_userid_already_exists: d2d95ece4b06507be18c9ba240b0a26b
|
||||
environments/contacts/contact_deleted_successfully: c5b64a42a50e055f9e27ec49e20e03fa
|
||||
environments/contacts/contact_not_found: 045396f0b13fafd43612a286263737c0
|
||||
environments/contacts/contacts_table_refresh: 6a959475991dd4ab28ad881bae569a09
|
||||
environments/contacts/contacts_table_refresh_success: 40951396e88e5c8fdafa0b3bb4fadca8
|
||||
environments/contacts/create_attribute: 87320615901f95b4f35ee83c290a3a6c
|
||||
environments/contacts/create_key: 0d385c354af8963acbe35cd646710f86
|
||||
environments/contacts/create_new_attribute: c17d407dacd0b90f360f9f5e899d662f
|
||||
environments/contacts/create_new_attribute_description: cc19d76bb6940537bbe3461191f25d26
|
||||
environments/contacts/custom_attributes: fffc7722742d1291b102dc737cf2fc9e
|
||||
environments/contacts/data_type: 1ea127ba2c18d0d91fb0361cc6747e2b
|
||||
environments/contacts/data_type_cannot_be_changed: 22603f6193fdac3784eeef8315df70de
|
||||
environments/contacts/data_type_description: 800bf4935df15e6cb14269e1b60c506e
|
||||
environments/contacts/date_value_required: e0e75b75ae4e8c02f03284954756adc9
|
||||
environments/contacts/delete_attribute_confirmation: 01d99b89eb3d27ff468d0db1b4aeb394
|
||||
environments/contacts/delete_contact_confirmation: 2d45579e0bb4bc40fb1ee75b43c0e7a4
|
||||
environments/contacts/delete_contact_confirmation_with_quotas: d3d17f13ae46ce04c126c82bf01299ac
|
||||
environments/contacts/displays: fcc4527002bd045021882be463b8ac72
|
||||
environments/contacts/edit_attribute: 92a83c96a5d850e7d39002e8fd5898f4
|
||||
environments/contacts/edit_attribute_description: 073a3084bb2f3b34ed1320ed1cd6db3c
|
||||
environments/contacts/edit_attribute_values: 44e4e7a661cc1b59200bb07c710072a7
|
||||
environments/contacts/edit_attribute_values_description: 21593dfaf4cad965ffc17685bc005509
|
||||
environments/contacts/edit_attributes: a5c3b540441d34b4c0b7faab8f0f0c89
|
||||
environments/contacts/edit_attributes_success: 39f93b1a6f1605bc5951f4da5847bb22
|
||||
environments/contacts/generate_personal_link: 9ac0865f6876d40fe858f94eae781eb8
|
||||
environments/contacts/generate_personal_link_description: b9dbaf9e2d8362505b7e3cfa40f415a6
|
||||
environments/contacts/invalid_csv_column_names: dcb8534e7d4c00b9ea7bdaf389f72328
|
||||
environments/contacts/invalid_date_format: 5bad9730ac5a5bacd0792098f712b1c4
|
||||
environments/contacts/invalid_number_format: bd0422507385f671c3046730a6febc64
|
||||
environments/contacts/no_activity_yet: f88897ac05afd6bf8af0d4834ad24ffc
|
||||
environments/contacts/no_published_link_surveys_available: 9c1abc5b21aba827443cdf87dd6c8bfe
|
||||
environments/contacts/no_published_surveys: bd945b0e2e2328c17615c94143bdd62b
|
||||
environments/contacts/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/contacts/not_provided: a09e4d61bbeb04b927406a50116445e2
|
||||
environments/contacts/number_value_required: d82a198a378eb120f3329e4d3fd4d3f7
|
||||
environments/contacts/personal_link_generated: efb7a0420bd459847eb57bca41a4ab0d
|
||||
environments/contacts/personal_link_generated_but_clipboard_failed: 4eb1e208e729bd5ac00c33f72fc38d53
|
||||
environments/contacts/personal_survey_link: 5b3f1afc53733718c4ed5b1443b6a604
|
||||
@@ -623,13 +655,24 @@ checksums:
|
||||
environments/contacts/search_contact: 020205a93846ab3e12c203ac4fa97c12
|
||||
environments/contacts/select_a_survey: 1f49086dfb874307aae1136e88c3d514
|
||||
environments/contacts/select_attribute: d93fb60eb4fbb42bf13a22f6216fbd79
|
||||
environments/contacts/select_attribute_key: 673a6683fab41b387d921841cded7e38
|
||||
environments/contacts/survey_viewed: 646d413218626787b0373ffd71cb7451
|
||||
environments/contacts/survey_viewed_at: 2ab535237af5c3c3f33acc792a7e70a4
|
||||
environments/contacts/system_attributes: eadb6a8888c7b32c0e68881f945ae9b6
|
||||
environments/contacts/unlock_contacts_description: c5572047f02b4c39e5109f9de715499d
|
||||
environments/contacts/unlock_contacts_title: a8b3d7db03eb404d9267fd5cdd6d5ddb
|
||||
environments/contacts/upload_contacts_error_attribute_type_mismatch: 70a60f0886ce767c00defa7d4aad0f93
|
||||
environments/contacts/upload_contacts_error_duplicate_mappings: 9c1e1f07e476226bad98ccfa07979fec
|
||||
environments/contacts/upload_contacts_error_file_too_large: 0c1837286c55d18049277465bc2444c1
|
||||
environments/contacts/upload_contacts_error_generic: 3a8d35a421b377198361af9972392693
|
||||
environments/contacts/upload_contacts_error_invalid_file_type: 15ef4fa7c2d5273b05a042f398655e81
|
||||
environments/contacts/upload_contacts_error_no_valid_contacts: 27fbd24ed2d2fa3b6ed7b3a8c1dad343
|
||||
environments/contacts/upload_contacts_modal_attribute_header: 263246ad2a76f8e2f80f0ed175d7629a
|
||||
environments/contacts/upload_contacts_modal_attributes_description: e2cedbd4a043423002cbb2048e2145ac
|
||||
environments/contacts/upload_contacts_modal_attributes_new: 9829382598c681de74130440a37b560f
|
||||
environments/contacts/upload_contacts_modal_attributes_search_or_add: 1874839e465650d353282b43b00247a9
|
||||
environments/contacts/upload_contacts_modal_attributes_should_be_mapped_to: 693dfe5836e90b1c4c7c65b015418174
|
||||
environments/contacts/upload_contacts_modal_attributes_title: 86d0ae6fea0fbb119722ed3841f8385a
|
||||
environments/contacts/upload_contacts_modal_csv_column_header: f181add48fb8325efaa40579fe8c343e
|
||||
environments/contacts/upload_contacts_modal_description: 41566d40d25cc882aa9f82d87b4e2f03
|
||||
environments/contacts/upload_contacts_modal_download_example_csv: 7a186fc4941b264452ee6c9e785769de
|
||||
environments/contacts/upload_contacts_modal_duplicates_description: 112ce4641088520469a83a0bd740b073
|
||||
@@ -681,7 +724,12 @@ checksums:
|
||||
environments/integrations/google_sheets/link_google_sheet: fa78146ae26ce5b1d2aaf2678f628943
|
||||
environments/integrations/google_sheets/link_new_sheet: 8ad2ea8708f50ed184c00b84577b325e
|
||||
environments/integrations/google_sheets/no_integrations_yet: ea46f7747937baf48a47a4c1b1776aee
|
||||
environments/integrations/google_sheets/reconnect_button: 8992a0f250278c116cb26be448b68ba2
|
||||
environments/integrations/google_sheets/reconnect_button_description: 851fd2fda57211293090f371d5b2c734
|
||||
environments/integrations/google_sheets/reconnect_button_tooltip: 210dd97470fde8264d2c076db3c98fde
|
||||
environments/integrations/google_sheets/spreadsheet_permission_error: 94f0007a187d3b9a7ab8200fe26aad20
|
||||
environments/integrations/google_sheets/spreadsheet_url: b1665f96e6ecce23ea2d9196f4a3e5dd
|
||||
environments/integrations/google_sheets/token_expired_error: 555d34c18c554ec8ac66614f21bd44fc
|
||||
environments/integrations/include_created_at: 8011355b13e28e638d74e6f3d68a2bbf
|
||||
environments/integrations/include_hidden_fields: 25f0ea5ca1c6ead2cd121f8754cb8d72
|
||||
environments/integrations/include_metadata: 750091d965d7cc8d02468b5239816dc5
|
||||
@@ -796,6 +844,40 @@ checksums:
|
||||
environments/segments/no_attributes_yet: 57beecc917dcd598ccdd0ccfb364a960
|
||||
environments/segments/no_filters_yet: d885a68516840e15dd27f1c17d9a8975
|
||||
environments/segments/no_segments_yet: 6307a4163a5bd553bb2aba074d24be9c
|
||||
environments/segments/operator_contains: 06dd606c0a8f81f9a03b414e9ae89440
|
||||
environments/segments/operator_does_not_contain: 854da2bdf10613ce62fb454bab16c58b
|
||||
environments/segments/operator_ends_with: 2bd866369766c6a2ef74bb9fa74b1d7e
|
||||
environments/segments/operator_is_after: f9d9296eb9a5a7d168cc4e65a4095a87
|
||||
environments/segments/operator_is_before: 2462480cf4e8d2832b64004fbd463e55
|
||||
environments/segments/operator_is_between: 41ff45044d8a017a8a74f72be57916b8
|
||||
environments/segments/operator_is_newer_than: c41e03366623caed6b2c224e50387614
|
||||
environments/segments/operator_is_not_set: 906801489132487ef457652af4835142
|
||||
environments/segments/operator_is_older_than: acca6b309da507bbc5973c4b56b698b0
|
||||
environments/segments/operator_is_same_day: c06506b6eb9f6491f15685baccd68897
|
||||
environments/segments/operator_is_set: 9850468156356f95884bbaf56b6687aa
|
||||
environments/segments/operator_starts_with: 37e55e9080c84a1855956161e7885c21
|
||||
environments/segments/operator_title_contains: 41c8c25407527a5336404313f4c8d650
|
||||
environments/segments/operator_title_does_not_contain: d618eb0f854f7efa0d7c644e6628fa42
|
||||
environments/segments/operator_title_ends_with: c8a5f60f1bd1d8fa018dbbf49806fb5b
|
||||
environments/segments/operator_title_equals: 73439e2839b8049e68079b1b6f2e3c41
|
||||
environments/segments/operator_title_greater_equal: 556b342cee0ac7055171e41be80f49e4
|
||||
environments/segments/operator_title_greater_than: e06dabbbf3a9c527502c997101edab40
|
||||
environments/segments/operator_title_is_after: bd4cf644e442fca330cb483528485e5f
|
||||
environments/segments/operator_title_is_before: a47ce3825c5c7cea7ed7eb8d5505a2d5
|
||||
environments/segments/operator_title_is_between: 5721c877c60f0005dc4ce78d4c0d3fdc
|
||||
environments/segments/operator_title_is_newer_than: 133731671413c702a55cdfb9134d63f8
|
||||
environments/segments/operator_title_is_not_set: c1a6fd89387686d3a5426a768bb286e9
|
||||
environments/segments/operator_title_is_older_than: 9064cd482f2312c8b10aee4937d0278d
|
||||
environments/segments/operator_title_is_same_day: 9340bf7bd6ab504d71b0e957ca9fcf4c
|
||||
environments/segments/operator_title_is_set: 1c66019bd162201db83aef305ab2a161
|
||||
environments/segments/operator_title_less_equal: 235dbef60cd0af5ff1d319aab24a1109
|
||||
environments/segments/operator_title_less_than: e9f3c9742143760b28bf4e326f63a97b
|
||||
environments/segments/operator_title_not_equals: a186482f46739c9fe8683826a1cab723
|
||||
environments/segments/operator_title_starts_with: f6673c17475708313c6a0f245b561781
|
||||
environments/segments/operator_title_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
|
||||
environments/segments/operator_title_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
|
||||
environments/segments/operator_user_is_in: 33ecd1bc30f56d97133368f1b244ee4b
|
||||
environments/segments/operator_user_is_not_in: 99d576a3611d171947fd88c317aaf5f3
|
||||
environments/segments/person_and_attributes: 507023d577326a6326dd9603dcdc589d
|
||||
environments/segments/phone: b9537ee90fc5b0116942e0af29d926cc
|
||||
environments/segments/please_remove_the_segment_from_these_surveys_in_order_to_delete_it: 1858a8ae40bed3a8c06c3bb518e0b8aa
|
||||
@@ -820,6 +902,7 @@ checksums:
|
||||
environments/segments/user_targeting_is_currently_only_available_when: 9785f159fb045607b62461f38e8d3aee
|
||||
environments/segments/value_cannot_be_empty: 99efd449ec19f1ecc5cf0b6807d4f315
|
||||
environments/segments/value_must_be_a_number: 87516b5c69e08741fa8a6ddf64d60deb
|
||||
environments/segments/value_must_be_positive: d17ad009f7845a6fbeddeb2aef532e10
|
||||
environments/segments/view_filters: 791cd4bacb11e3eb0ffccee131270561
|
||||
environments/segments/where: 23aecda7d27f26121b057ec7f7327069
|
||||
environments/segments/with_the_formbricks_sdk: 2b185e6242edb69e1bc6e64e10dfc02a
|
||||
@@ -941,7 +1024,7 @@ checksums:
|
||||
environments/settings/general/email_customization_preview_email_heading: 8b798cb8438b3dd356c02dab33b4c897
|
||||
environments/settings/general/email_customization_preview_email_text: fa6ae92403cc8f3c35c03e6c94cbde51
|
||||
environments/settings/general/error_deleting_organization_please_try_again: 7f0fe257d4a0b40bff025408a7766706
|
||||
environments/settings/general/from_your_organization: 4b7970431edb3d0f13c394dbd755a055
|
||||
environments/settings/general/from_your_organization: 9ebd6dcd79f7bfad3fea46ed2e3133d2
|
||||
environments/settings/general/invitation_sent_once_more: e6e5ea066810f9dcb65788aa4f05d6e2
|
||||
environments/settings/general/invite_deleted_successfully: 1c7dca6d0f6870d945288e38cfd2f943
|
||||
environments/settings/general/invite_expires_on: 6fd2356ad91a5f189070c43855904bb4
|
||||
@@ -1096,6 +1179,7 @@ checksums:
|
||||
environments/surveys/edit/add_fallback_placeholder: 0e77ea487ddd7bc7fc2f1574b018dc08
|
||||
environments/surveys/edit/add_hidden_field_id: a8f55b51b790cf5f4d898af7770ad1ed
|
||||
environments/surveys/edit/add_highlight_border: 66f52b21fbb9aa6561c98a090abaaf8f
|
||||
environments/surveys/edit/add_highlight_border_description: fe548fe03ea10ef5cd9e553d6812b3c2
|
||||
environments/surveys/edit/add_logic: f234c9f1393a9ed4792dfbd15838c951
|
||||
environments/surveys/edit/add_none_of_the_above: dbe1ada4512d6c3f80c54c8fac107ec6
|
||||
environments/surveys/edit/add_option: 143c54f0b201067fe5159284d6daeca2
|
||||
@@ -1294,7 +1378,6 @@ checksums:
|
||||
environments/surveys/edit/follow_ups_modal_updated_successfull_toast: 61204fada3231f4f1fe3866e87e1130a
|
||||
environments/surveys/edit/follow_ups_new: 224c779d252b3e75086e4ed456ba2548
|
||||
environments/surveys/edit/follow_ups_upgrade_button_text: 4cd167527fc6cdb5b0bfc9b486b142a8
|
||||
environments/surveys/edit/form_styling: 1278a2db4257b5500474161133acc857
|
||||
environments/surveys/edit/formbricks_sdk_is_not_connected: 35165b0cac182a98408007a378cc677e
|
||||
environments/surveys/edit/four_points: b289628a6b8a6cd0f7d17a14ca6cd7bf
|
||||
environments/surveys/edit/heading: 79e9dfa461f38a239d34b9833ca103f1
|
||||
@@ -1465,7 +1548,7 @@ checksums:
|
||||
environments/surveys/edit/response_limits_redirections_and_more: e4f1cf94e56ad0e1b08701158d688802
|
||||
environments/surveys/edit/response_options: 2988136d5248d7726583108992dcbaee
|
||||
environments/surveys/edit/roundness: 5a161c8f5f258defb57ed1d551737cc4
|
||||
environments/surveys/edit/roundness_description: bde131aa5674836416dcdf2ff517d899
|
||||
environments/surveys/edit/roundness_description: 03940a6871ae43efa4810cba7cadb74b
|
||||
environments/surveys/edit/row_used_in_logic_error: f89453ff1b6db77ad84af840fedd9813
|
||||
environments/surveys/edit/rows: 8f41f34e6ca28221cf1ebd948af4c151
|
||||
environments/surveys/edit/save_and_close: 6ede705b3f82f30269ff3054a5049e34
|
||||
@@ -1511,6 +1594,7 @@ checksums:
|
||||
environments/surveys/edit/survey_completed_subheading: db537c356c3ab6564d24de0d11a0fee2
|
||||
environments/surveys/edit/survey_display_settings: 8ed19e6a8e1376f7a1ba037d82c4ae11
|
||||
environments/surveys/edit/survey_placement: 083c10f257337f9648bf9d435b18ec2c
|
||||
environments/surveys/edit/survey_styling: 7f96d6563e934e65687b74374a33b1dc
|
||||
environments/surveys/edit/survey_trigger: f0c7014a684ca566698b87074fad5579
|
||||
environments/surveys/edit/switch_multi_language_on_to_get_started: cca0ef91ee49095da30cd1e3f26c406f
|
||||
environments/surveys/edit/target_block_not_found: 0a0c401017ab32364fec2fcbf815d832
|
||||
@@ -1599,7 +1683,6 @@ checksums:
|
||||
environments/surveys/edit/welcome_message: 986a434e3895c8ee0b267df95cc40051
|
||||
environments/surveys/edit/without_a_filter_all_of_your_users_can_be_surveyed: 451990569c61f25d01044cc45b1ce122
|
||||
environments/surveys/edit/you_have_not_created_a_segment_yet: c6658bd1cee9c5c957c675db044708dd
|
||||
environments/surveys/edit/you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations: 04241177ba989ef4c1d8c01e1a7b8541
|
||||
environments/surveys/edit/your_description_here_recall_information_with: 60f73a3cc9bdb9afea2166a7db8fd618
|
||||
environments/surveys/edit/your_question_here_recall_information_with: 6395bd54f5167830c9d662ba403da167
|
||||
environments/surveys/edit/your_web_app: 07234bed03a33330dc50ae9fcf0174f3
|
||||
@@ -1781,6 +1864,7 @@ checksums:
|
||||
environments/surveys/summary/filtered_responses_excel: 06e57bae9e41979fd7fc4b8bfe3466f9
|
||||
environments/surveys/summary/generating_qr_code: 5026d4a76f995db458195e5215d9bbd9
|
||||
environments/surveys/summary/impressions: 7fe38d42d68a64d3fd8436a063751584
|
||||
environments/surveys/summary/impressions_identified_only: 10f8c491463c73b8e6534314ee00d165
|
||||
environments/surveys/summary/impressions_tooltip: 4d0823cbf360304770c7c5913e33fdc8
|
||||
environments/surveys/summary/in_app/connection_description: 9710bbf8048a8a5c3b2b56db9d946b73
|
||||
environments/surveys/summary/in_app/connection_title: 29e8a40ad6a7fdb5af5ee9451a70a9aa
|
||||
@@ -1821,6 +1905,7 @@ checksums:
|
||||
environments/surveys/summary/last_quarter: 2e565a81de9b3d7b1ee709ebb6f6eda1
|
||||
environments/surveys/summary/last_year: fe7c268a48bf85bc40da000e6e437637
|
||||
environments/surveys/summary/limit: 347051f1a068e01e8c4e4f6744d8e727
|
||||
environments/surveys/summary/no_identified_impressions: c3bc42e6feb9010ced905ded51c5afc4
|
||||
environments/surveys/summary/no_responses_found: f10190cffdda4ca1bed479acbb89b13f
|
||||
environments/surveys/summary/other_values_found: 48a74ee68c05f7fb162072b50c683b6a
|
||||
environments/surveys/summary/overall: 6c6d6533013d4739766af84b2871bca6
|
||||
@@ -1843,6 +1928,7 @@ checksums:
|
||||
environments/surveys/summary/starts: 3153990a4ade414f501a7e63ab771362
|
||||
environments/surveys/summary/starts_tooltip: 0a7dd01320490dbbea923053fa1ccad6
|
||||
environments/surveys/summary/survey_reset_successfully: f53db36a28980ef4766215cf13f01e51
|
||||
environments/surveys/summary/survey_results: b7d86f636beaee2b4d5746bdda058d07
|
||||
environments/surveys/summary/this_month: 50845a38865204a97773c44dcd2ebb90
|
||||
environments/surveys/summary/this_quarter: 9c77d94783dff2269c069389122cd7bd
|
||||
environments/surveys/summary/this_year: 1e69651c2ac722f8ce138f43cf2e02f9
|
||||
@@ -1962,7 +2048,7 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_description_size: a0d51c3ab7dc56320ecedc2b27917842
|
||||
environments/workspace/look/advanced_styling_field_description_size_description: ff880ea1beddd1b1ec7416d0b8a69cf3
|
||||
environments/workspace/look/advanced_styling_field_description_weight: 514680cc7202ad29835c1cbcde3def1c
|
||||
environments/workspace/look/advanced_styling_field_description_weight_description: 441ac8db1a32557813eb68fbfd759061
|
||||
environments/workspace/look/advanced_styling_field_description_weight_description: aa95bc81b5336a548e256bce49350683
|
||||
environments/workspace/look/advanced_styling_field_font_size: ca44d14429b2175a1b194793b4ab8f6b
|
||||
environments/workspace/look/advanced_styling_field_font_weight: bfef83778146cf40550df9650d8a07da
|
||||
environments/workspace/look/advanced_styling_field_headline_color: 4ccf3935ad90c88ad4add24f498673ce
|
||||
@@ -1971,12 +2057,12 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_headline_size_description: 13debc3855e4edae992c7a1ebff599c3
|
||||
environments/workspace/look/advanced_styling_field_headline_weight: 0c8b8262945c61f8e2978502362e0a42
|
||||
environments/workspace/look/advanced_styling_field_headline_weight_description: 1a9c40bd76ff5098b1e48b1d3893171b
|
||||
environments/workspace/look/advanced_styling_field_height: f4da6d7ecd26e3fa75cfea03abb60c00
|
||||
environments/workspace/look/advanced_styling_field_height: 40ca2224bb2936ad1329091b35a9ffe2
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg: 00febda2901af0f1b0c17e44f9917c38
|
||||
environments/workspace/look/advanced_styling_field_indicator_bg_description: 7eb3b54a8b331354ec95c0dc1545c620
|
||||
environments/workspace/look/advanced_styling_field_input_border_radius_description: 0007f1bb572b35d9a3720daeb7a55617
|
||||
environments/workspace/look/advanced_styling_field_input_font_size_description: 5311f95dcbd083623e35c98ea5374c3b
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: b704fc67e805223992c811d6f86a9c00
|
||||
environments/workspace/look/advanced_styling_field_input_height_description: bb7439d42ec3848a8fa9edb8b001b69a
|
||||
environments/workspace/look/advanced_styling_field_input_padding_x_description: 10e14296468321c13fda77fd1ba58dfd
|
||||
environments/workspace/look/advanced_styling_field_input_padding_y_description: 98b4aeff2940516d05ea61bdc1211d0d
|
||||
environments/workspace/look/advanced_styling_field_input_placeholder_opacity_description: f55a6700884d24014404e58876121ddf
|
||||
@@ -1985,6 +2071,8 @@ checksums:
|
||||
environments/workspace/look/advanced_styling_field_input_text_description: 460450df24ea0cc902710118a5000feb
|
||||
environments/workspace/look/advanced_styling_field_option_bg: 0ceaed10d99ed4ad83cb0934ab970174
|
||||
environments/workspace/look/advanced_styling_field_option_bg_description: 6cd6ccecbbb9f2f19439d7c682eb67c1
|
||||
environments/workspace/look/advanced_styling_field_option_border: aa478eb148515b6a2637fb144ff72028
|
||||
environments/workspace/look/advanced_styling_field_option_border_description: 8f75b740e8dcb7f6cfeff2e5d5ca7c92
|
||||
environments/workspace/look/advanced_styling_field_option_border_radius_description: 23f81c25b2681a7c9e2c4f2e7d2e0656
|
||||
environments/workspace/look/advanced_styling_field_option_font_size_description: 5430fd9b08819972f0a613bf3fa659da
|
||||
environments/workspace/look/advanced_styling_field_option_label: 2767a5db32742073a01aac16488e93dc
|
||||
@@ -2039,6 +2127,7 @@ checksums:
|
||||
environments/workspace/look/show_powered_by_formbricks: a0e96edadec8ef326423feccc9d06be7
|
||||
environments/workspace/look/styling_updated_successfully: b8b74b50dde95abcd498633e9d0c891f
|
||||
environments/workspace/look/suggest_colors: ddc4543b416ab774007b10a3434343cd
|
||||
environments/workspace/look/suggested_colors_applied_please_save: 226fa70af5efc8ffa0a3755909c8163e
|
||||
environments/workspace/look/theme: 21fe00b7a518089576fb83c08631107a
|
||||
environments/workspace/look/theme_settings_description: 9fc45322818c3774ab4a44ea14d7836e
|
||||
environments/workspace/tags/add: 87c4a663507f2bcbbf79934af8164e13
|
||||
@@ -2765,6 +2854,9 @@ checksums:
|
||||
templates/preview_survey_question_2_choice_2_label: 1af148222f327f28cf0db6513de5989e
|
||||
templates/preview_survey_question_2_headline: 5cfb173d156555227fbc2c97ad921e72
|
||||
templates/preview_survey_question_2_subheader: 2e652d8acd68d072e5a0ae686c4011c0
|
||||
templates/preview_survey_question_open_text_headline: a9509a47e0456ae98ec3ddac3d6fad2c
|
||||
templates/preview_survey_question_open_text_placeholder: 37ee9c84f3777b9220d4faec1e1c78ee
|
||||
templates/preview_survey_question_open_text_subheader: 3c7bf09f3f17b02bc2fbbbdb347a5830
|
||||
templates/preview_survey_welcome_card_headline: 8778dc41547a2778d0f9482da989fc00
|
||||
templates/prioritize_features_description: 1eae41fad0e3947f803d8539081e59ec
|
||||
templates/prioritize_features_name: 4ca59ff1f9c319aaa68c3106d820fd6a
|
||||
|
||||
@@ -63,7 +63,8 @@ export const INVITE_DISABLED = env.INVITE_DISABLED === "1";
|
||||
|
||||
export const SLACK_CLIENT_SECRET = env.SLACK_CLIENT_SECRET;
|
||||
export const SLACK_CLIENT_ID = env.SLACK_CLIENT_ID;
|
||||
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read`;
|
||||
export const SLACK_REDIRECT_URI = `${WEBAPP_URL}/api/v1/integrations/slack/callback`;
|
||||
export const SLACK_AUTH_URL = `https://slack.com/oauth/v2/authorize?client_id=${env.SLACK_CLIENT_ID}&scope=channels:read,chat:write,chat:write.public,chat:write.customize,groups:read&redirect_uri=${SLACK_REDIRECT_URI}`;
|
||||
|
||||
export const GOOGLE_SHEETS_CLIENT_ID = env.GOOGLE_SHEETS_CLIENT_ID;
|
||||
export const GOOGLE_SHEETS_CLIENT_SECRET = env.GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { cache as reactCache } from "react";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "@formbricks/database";
|
||||
import { ZId } from "@formbricks/types/common";
|
||||
import { TDisplay, TDisplayFilters } from "@formbricks/types/displays";
|
||||
import { TDisplay, TDisplayFilters, TDisplayWithContact } from "@formbricks/types/displays";
|
||||
import { DatabaseError } from "@formbricks/types/errors";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
|
||||
@@ -23,13 +24,12 @@ export const getDisplayCountBySurveyId = reactCache(
|
||||
const displayCount = await prisma.display.count({
|
||||
where: {
|
||||
surveyId: surveyId,
|
||||
...(filters &&
|
||||
filters.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
...(filters?.createdAt && {
|
||||
createdAt: {
|
||||
gte: filters.createdAt.min,
|
||||
lte: filters.createdAt.max,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
return displayCount;
|
||||
@@ -42,6 +42,97 @@ export const getDisplayCountBySurveyId = reactCache(
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplaysByContactId = reactCache(
|
||||
async (contactId: string): Promise<Pick<TDisplay, "id" | "createdAt" | "surveyId">[]> => {
|
||||
validateInputs([contactId, ZId]);
|
||||
|
||||
try {
|
||||
const displays = await prisma.display.findMany({
|
||||
where: { contactId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return displays;
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const getDisplaysBySurveyIdWithContact = reactCache(
|
||||
async (surveyId: string, limit?: number, offset?: number): Promise<TDisplayWithContact[]> => {
|
||||
validateInputs(
|
||||
[surveyId, ZId],
|
||||
[limit, z.number().int().min(1).optional()],
|
||||
[offset, z.number().int().nonnegative().optional()]
|
||||
);
|
||||
|
||||
try {
|
||||
const displays = await prisma.display.findMany({
|
||||
where: {
|
||||
surveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
return displays.map((display) => ({
|
||||
id: display.id,
|
||||
createdAt: display.createdAt,
|
||||
surveyId: display.surveyId,
|
||||
contact: display.contact
|
||||
? {
|
||||
id: display.contact.id,
|
||||
attributes: display.contact.attributes.reduce(
|
||||
(acc, attr) => {
|
||||
acc[attr.attributeKey.key] = attr.value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError) {
|
||||
throw new DatabaseError(error.message);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
export const deleteDisplay = async (displayId: string, tx?: Prisma.TransactionClient): Promise<TDisplay> => {
|
||||
validateInputs([displayId, ZId]);
|
||||
try {
|
||||
|
||||
219
apps/web/lib/display/tests/display-queries.test.ts
Normal file
219
apps/web/lib/display/tests/display-queries.test.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import { mockDisplayId, mockSurveyId } from "./__mocks__/data.mock";
|
||||
import { prisma } from "@/lib/__mocks__/database";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { PrismaErrorType } from "@formbricks/database/types/error";
|
||||
import { DatabaseError, ValidationError } from "@formbricks/types/errors";
|
||||
import { getDisplaysByContactId, getDisplaysBySurveyIdWithContact } from "../service";
|
||||
|
||||
const mockContactId = "clqnj99r9000008lebgf8734j";
|
||||
|
||||
const mockDisplaysForContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisplaysWithContact = [
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: [
|
||||
{ attributeKey: { key: "email" }, value: "test@example.com" },
|
||||
{ attributeKey: { key: "userId" }, value: "user-123" },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: [{ attributeKey: { key: "userId" }, value: "user-456" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("getDisplaysByContactId", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays for a contact ordered by createdAt desc", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysForContact as any);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual(mockDisplaysForContact);
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: { contactId: mockContactId },
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when contact has no displays", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysByContactId(mockContactId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the contactId is invalid", async () => {
|
||||
await expect(getDisplaysByContactId("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysByContactId(mockContactId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDisplaysBySurveyIdWithContact", () => {
|
||||
describe("Happy Path", () => {
|
||||
test("returns displays with contact attributes transformed", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue(mockDisplaysWithContact as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: {
|
||||
id: mockContactId,
|
||||
attributes: { email: "test@example.com", userId: "user-123" },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "clqkr5smu000208jy50v6g5k5",
|
||||
createdAt: new Date("2024-01-14T10:00:00Z"),
|
||||
surveyId: "clqkr8dlv000308jybb08evgs",
|
||||
contact: {
|
||||
id: "clqnj99r9000008lebgf8734k",
|
||||
attributes: { userId: "user-456" },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("calls prisma with correct where clause and pagination", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
await getDisplaysBySurveyIdWithContact(mockSurveyId, 15, 0);
|
||||
|
||||
expect(prisma.display.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
surveyId: mockSurveyId,
|
||||
contactId: { not: null },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
createdAt: true,
|
||||
surveyId: true,
|
||||
contact: {
|
||||
select: {
|
||||
id: true,
|
||||
attributes: {
|
||||
where: {
|
||||
attributeKey: {
|
||||
key: { in: ["email", "userId"] },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
attributeKey: { select: { key: true } },
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 15,
|
||||
skip: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("returns empty array when no displays found", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([]);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("handles display with null contact", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockResolvedValue([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
] as any);
|
||||
|
||||
const result = await getDisplaysBySurveyIdWithContact(mockSurveyId);
|
||||
|
||||
expect(result).toEqual([
|
||||
{
|
||||
id: mockDisplayId,
|
||||
createdAt: new Date("2024-01-15T10:00:00Z"),
|
||||
surveyId: mockSurveyId,
|
||||
contact: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Sad Path", () => {
|
||||
test("throws a ValidationError if the surveyId is invalid", async () => {
|
||||
await expect(getDisplaysBySurveyIdWithContact("not-a-cuid")).rejects.toThrow(ValidationError);
|
||||
});
|
||||
|
||||
test("throws DatabaseError on PrismaClientKnownRequestError", async () => {
|
||||
const errToThrow = new Prisma.PrismaClientKnownRequestError("Mock error", {
|
||||
code: PrismaErrorType.UniqueConstraintViolation,
|
||||
clientVersion: "0.0.1",
|
||||
});
|
||||
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(errToThrow);
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(DatabaseError);
|
||||
});
|
||||
|
||||
test("throws generic Error for other exceptions", async () => {
|
||||
vi.mocked(prisma.display.findMany).mockRejectedValue(new Error("Mock error"));
|
||||
|
||||
await expect(getDisplaysBySurveyIdWithContact(mockSurveyId)).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -167,6 +167,12 @@ export const createEnvironment = async (
|
||||
description: "Your contact's last name",
|
||||
type: "default",
|
||||
},
|
||||
{
|
||||
key: "language",
|
||||
name: "Language",
|
||||
description: "The language preference of a contact",
|
||||
type: "default",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,44 +1,66 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
// Mock constants module
|
||||
const envMock = {
|
||||
env: {
|
||||
WEBAPP_URL: "http://localhost:3000",
|
||||
PUBLIC_URL: undefined as string | undefined,
|
||||
},
|
||||
WEBAPP_URL: undefined as string | undefined,
|
||||
VERCEL_URL: undefined as string | undefined,
|
||||
PUBLIC_URL: undefined as string | undefined,
|
||||
};
|
||||
|
||||
vi.mock("@/lib/env", () => envMock);
|
||||
vi.mock("./env", () => ({
|
||||
env: envMock,
|
||||
}));
|
||||
|
||||
const loadGetPublicDomain = async () => {
|
||||
vi.resetModules();
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
return getPublicDomain;
|
||||
};
|
||||
|
||||
describe("getPublicDomain", () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
envMock.WEBAPP_URL = undefined;
|
||||
envMock.VERCEL_URL = undefined;
|
||||
envMock.PUBLIC_URL = undefined;
|
||||
});
|
||||
|
||||
test("should return WEBAPP_URL when PUBLIC_URL is not set", async () => {
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
test("returns trimmed WEBAPP_URL when configured", async () => {
|
||||
envMock.WEBAPP_URL = " https://app.formbricks.com ";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://app.formbricks.com");
|
||||
});
|
||||
|
||||
test("should return PUBLIC_URL when it is set", async () => {
|
||||
envMock.env.PUBLIC_URL = "https://surveys.example.com";
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("https://surveys.example.com");
|
||||
test("falls back to VERCEL_URL when WEBAPP_URL is empty", async () => {
|
||||
envMock.WEBAPP_URL = " ";
|
||||
envMock.VERCEL_URL = "preview.formbricks.com";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://preview.formbricks.com");
|
||||
});
|
||||
|
||||
test("should handle empty string PUBLIC_URL by returning WEBAPP_URL", async () => {
|
||||
envMock.env.PUBLIC_URL = "";
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
test("falls back to localhost when WEBAPP_URL and VERCEL_URL are not set", async () => {
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("http://localhost:3000");
|
||||
});
|
||||
|
||||
test("should handle undefined PUBLIC_URL by returning WEBAPP_URL", async () => {
|
||||
envMock.env.PUBLIC_URL = undefined;
|
||||
const { getPublicDomain } = await import("./getPublicUrl");
|
||||
const domain = getPublicDomain();
|
||||
expect(domain).toBe("http://localhost:3000");
|
||||
test("returns PUBLIC_URL when set", async () => {
|
||||
envMock.WEBAPP_URL = "https://app.formbricks.com";
|
||||
envMock.PUBLIC_URL = "https://surveys.formbricks.com";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://surveys.formbricks.com");
|
||||
});
|
||||
|
||||
test("falls back to WEBAPP_URL when PUBLIC_URL is empty", async () => {
|
||||
envMock.WEBAPP_URL = "https://app.formbricks.com";
|
||||
envMock.PUBLIC_URL = " ";
|
||||
|
||||
const getPublicDomain = await loadGetPublicDomain();
|
||||
|
||||
expect(getPublicDomain()).toBe("https://app.formbricks.com");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import "server-only";
|
||||
import { env } from "./env";
|
||||
|
||||
const WEBAPP_URL =
|
||||
env.WEBAPP_URL ?? (env.VERCEL_URL ? `https://${env.VERCEL_URL}` : "") ?? "http://localhost:3000";
|
||||
const configuredWebappUrl = env.WEBAPP_URL?.trim() ?? "";
|
||||
const WEBAPP_URL = (() => {
|
||||
if (configuredWebappUrl !== "") {
|
||||
return configuredWebappUrl;
|
||||
}
|
||||
|
||||
if (env.VERCEL_URL) {
|
||||
return `https://${env.VERCEL_URL}`;
|
||||
}
|
||||
|
||||
return "http://localhost:3000";
|
||||
})();
|
||||
|
||||
/**
|
||||
* Returns the public domain URL
|
||||
|
||||
6
apps/web/lib/googleSheet/constants.ts
Normal file
6
apps/web/lib/googleSheet/constants.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Error codes returned by Google Sheets integration.
|
||||
* Use these constants when comparing error responses to avoid typos and enable reuse.
|
||||
*/
|
||||
export const GOOGLE_SHEET_INTEGRATION_INVALID_GRANT = "invalid_grant";
|
||||
export const GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION = "insufficient_permission";
|
||||
@@ -2,7 +2,12 @@ import "server-only";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { ZString } from "@formbricks/types/common";
|
||||
import { DatabaseError, UnknownError } from "@formbricks/types/errors";
|
||||
import {
|
||||
AuthenticationError,
|
||||
DatabaseError,
|
||||
OperationNotAllowedError,
|
||||
UnknownError,
|
||||
} from "@formbricks/types/errors";
|
||||
import {
|
||||
TIntegrationGoogleSheets,
|
||||
ZIntegrationGoogleSheets,
|
||||
@@ -11,8 +16,12 @@ import {
|
||||
GOOGLE_SHEETS_CLIENT_ID,
|
||||
GOOGLE_SHEETS_CLIENT_SECRET,
|
||||
GOOGLE_SHEETS_REDIRECT_URL,
|
||||
GOOGLE_SHEET_MESSAGE_LIMIT,
|
||||
} from "@/lib/constants";
|
||||
import { GOOGLE_SHEET_MESSAGE_LIMIT } from "@/lib/constants";
|
||||
import {
|
||||
GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION,
|
||||
GOOGLE_SHEET_INTEGRATION_INVALID_GRANT,
|
||||
} from "@/lib/googleSheet/constants";
|
||||
import { createOrUpdateIntegration } from "@/lib/integration/service";
|
||||
import { truncateText } from "../utils/strings";
|
||||
import { validateInputs } from "../utils/validate";
|
||||
@@ -81,6 +90,17 @@ export const writeData = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const validateGoogleSheetsConnection = async (
|
||||
googleSheetIntegrationData: TIntegrationGoogleSheets
|
||||
): Promise<void> => {
|
||||
validateInputs([googleSheetIntegrationData, ZIntegrationGoogleSheets]);
|
||||
const integrationData = structuredClone(googleSheetIntegrationData);
|
||||
integrationData.config.data.forEach((data) => {
|
||||
data.createdAt = new Date(data.createdAt);
|
||||
});
|
||||
await authorize(integrationData);
|
||||
};
|
||||
|
||||
export const getSpreadsheetNameById = async (
|
||||
googleSheetIntegrationData: TIntegrationGoogleSheets,
|
||||
spreadsheetId: string
|
||||
@@ -94,7 +114,17 @@ export const getSpreadsheetNameById = async (
|
||||
return new Promise((resolve, reject) => {
|
||||
sheets.spreadsheets.get({ spreadsheetId }, (err, response) => {
|
||||
if (err) {
|
||||
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
|
||||
const msg = err.message?.toLowerCase() ?? "";
|
||||
const isPermissionError =
|
||||
msg.includes("permission") ||
|
||||
msg.includes("caller does not have") ||
|
||||
msg.includes("insufficient permission") ||
|
||||
msg.includes("access denied");
|
||||
if (isPermissionError) {
|
||||
reject(new OperationNotAllowedError(GOOGLE_SHEET_INTEGRATION_INSUFFICIENT_PERMISSION));
|
||||
} else {
|
||||
reject(new UnknownError(`Error while fetching spreadsheet data: ${err.message}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
const spreadsheetTitle = response.data.properties.title;
|
||||
@@ -109,26 +139,70 @@ export const getSpreadsheetNameById = async (
|
||||
}
|
||||
};
|
||||
|
||||
const isInvalidGrantError = (error: unknown): boolean => {
|
||||
const err = error as { message?: string; response?: { data?: { error?: string } } };
|
||||
return (
|
||||
typeof err?.message === "string" &&
|
||||
err.message.toLowerCase().includes(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT)
|
||||
);
|
||||
};
|
||||
|
||||
/** Buffer in ms before expiry_date to consider token near-expired (5 minutes). */
|
||||
const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
||||
|
||||
const GOOGLE_TOKENINFO_URL = "https://www.googleapis.com/oauth2/v1/tokeninfo";
|
||||
|
||||
/**
|
||||
* Verifies that the access token is still valid and not revoked (e.g. user removed app access).
|
||||
* Returns true if token is valid, false if invalid/revoked.
|
||||
*/
|
||||
const isAccessTokenValid = async (accessToken: string): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`${GOOGLE_TOKENINFO_URL}?access_token=${encodeURIComponent(accessToken)}`);
|
||||
return res.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const authorize = async (googleSheetIntegrationData: TIntegrationGoogleSheets) => {
|
||||
const client_id = GOOGLE_SHEETS_CLIENT_ID;
|
||||
const client_secret = GOOGLE_SHEETS_CLIENT_SECRET;
|
||||
const redirect_uri = GOOGLE_SHEETS_REDIRECT_URL;
|
||||
const oAuth2Client = new google.auth.OAuth2(client_id, client_secret, redirect_uri);
|
||||
const refresh_token = googleSheetIntegrationData.config.key.refresh_token;
|
||||
oAuth2Client.setCredentials({
|
||||
refresh_token,
|
||||
});
|
||||
const { credentials } = await oAuth2Client.refreshAccessToken();
|
||||
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
data: googleSheetIntegrationData.config?.data ?? [],
|
||||
email: googleSheetIntegrationData.config?.email ?? "",
|
||||
key: credentials,
|
||||
},
|
||||
});
|
||||
const key = googleSheetIntegrationData.config.key;
|
||||
|
||||
oAuth2Client.setCredentials(credentials);
|
||||
const hasStoredCredentials =
|
||||
key.access_token && key.expiry_date && key.expiry_date > Date.now() + TOKEN_EXPIRY_BUFFER_MS;
|
||||
|
||||
return oAuth2Client;
|
||||
if (hasStoredCredentials && (await isAccessTokenValid(key.access_token))) {
|
||||
oAuth2Client.setCredentials(key);
|
||||
return oAuth2Client;
|
||||
}
|
||||
|
||||
oAuth2Client.setCredentials({ refresh_token: key.refresh_token });
|
||||
|
||||
try {
|
||||
const { credentials } = await oAuth2Client.refreshAccessToken();
|
||||
const mergedCredentials = {
|
||||
...credentials,
|
||||
refresh_token: credentials.refresh_token ?? key.refresh_token,
|
||||
};
|
||||
await createOrUpdateIntegration(googleSheetIntegrationData.environmentId, {
|
||||
type: "googleSheets",
|
||||
config: {
|
||||
data: googleSheetIntegrationData.config?.data ?? [],
|
||||
email: googleSheetIntegrationData.config?.email ?? "",
|
||||
key: mergedCredentials,
|
||||
},
|
||||
});
|
||||
|
||||
oAuth2Client.setCredentials(mergedCredentials);
|
||||
return oAuth2Client;
|
||||
} catch (error) {
|
||||
if (isInvalidGrantError(error)) {
|
||||
throw new AuthenticationError(GOOGLE_SHEET_INTEGRATION_INVALID_GRANT);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getElementsFromBlocks } from "@/lib/survey/utils";
|
||||
import { getIsQuotasEnabled } from "@/modules/ee/license-check/lib/utils";
|
||||
import { reduceQuotaLimits } from "@/modules/ee/quotas/lib/quotas";
|
||||
import { deleteFile } from "@/modules/storage/service";
|
||||
import { resolveStorageUrlsInObject } from "@/modules/storage/utils";
|
||||
import { getOrganizationIdFromEnvironmentId } from "@/modules/survey/lib/organization";
|
||||
import { getOrganizationBilling } from "@/modules/survey/lib/survey";
|
||||
import { ITEMS_PER_PAGE } from "../constants";
|
||||
@@ -396,7 +397,6 @@ export const getResponseDownloadFile = async (
|
||||
"Survey ID",
|
||||
"Formbricks ID (internal)",
|
||||
"User ID",
|
||||
"Notes",
|
||||
"Tags",
|
||||
...metaDataFields,
|
||||
...elements.flat(),
|
||||
@@ -408,9 +408,10 @@ export const getResponseDownloadFile = async (
|
||||
if (survey.isVerifyEmailEnabled) {
|
||||
headers.push("Verified Email");
|
||||
}
|
||||
const resolvedResponses = responses.map((r) => ({ ...r, data: resolveStorageUrlsInObject(r.data) }));
|
||||
const jsonData = getResponsesJson(
|
||||
survey,
|
||||
responses,
|
||||
resolvedResponses,
|
||||
elements,
|
||||
userAttributes,
|
||||
hiddenFields,
|
||||
|
||||
@@ -60,6 +60,7 @@ export const getSuggestedColors = (brandColor: string = DEFAULT_BRAND_COLOR) =>
|
||||
// Options (Radio / Checkbox)
|
||||
"optionBgColor.light": inputBg,
|
||||
"optionLabelColor.light": questionColor,
|
||||
"optionBorderColor.light": inputBorder,
|
||||
|
||||
// Card
|
||||
"cardBackgroundColor.light": cardBg,
|
||||
@@ -118,10 +119,10 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
// Inputs
|
||||
inputTextColor: { light: _colors["inputTextColor.light"] },
|
||||
inputBorderRadius: 8,
|
||||
inputHeight: 40,
|
||||
inputHeight: 20,
|
||||
inputFontSize: 14,
|
||||
inputPaddingX: 16,
|
||||
inputPaddingY: 16,
|
||||
inputPaddingX: 8,
|
||||
inputPaddingY: 8,
|
||||
inputPlaceholderOpacity: 0.5,
|
||||
inputShadow: "0 1px 2px 0 rgb(0 0 0 / 0.05)",
|
||||
|
||||
@@ -138,6 +139,7 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
// Options
|
||||
optionBgColor: { light: _colors["optionBgColor.light"] },
|
||||
optionLabelColor: { light: _colors["optionLabelColor.light"] },
|
||||
optionBorderColor: { light: _colors["optionBorderColor.light"] },
|
||||
optionBorderRadius: 8,
|
||||
optionPaddingX: 16,
|
||||
optionPaddingY: 16,
|
||||
@@ -149,6 +151,43 @@ export const STYLE_DEFAULTS: TProjectStyling = {
|
||||
progressIndicatorBgColor: { light: _colors["progressIndicatorBgColor.light"] },
|
||||
};
|
||||
|
||||
/**
|
||||
* Fills in new v4.7 color fields from legacy v4.6 fields when they are missing.
|
||||
*
|
||||
* v4.6 stored: brandColor, questionColor, inputColor, inputBorderColor.
|
||||
* v4.7 adds: elementHeadlineColor, buttonBgColor, optionBgColor, etc.
|
||||
*
|
||||
* When loading v4.6 data the new fields are absent. Without this helper the
|
||||
* form would fall back to STYLE_DEFAULTS (derived from the *default* brand
|
||||
* colour), causing a visible mismatch. This function derives the new fields
|
||||
* from the actually-saved legacy fields so the preview and form stay coherent.
|
||||
*
|
||||
* Only sets a field when the legacy source exists AND the new field is absent.
|
||||
*/
|
||||
export const deriveNewFieldsFromLegacy = (saved: Record<string, unknown>): Record<string, unknown> => {
|
||||
const light = (key: string): string | undefined =>
|
||||
(saved[key] as { light?: string } | null | undefined)?.light;
|
||||
|
||||
const q = light("questionColor");
|
||||
const b = light("brandColor");
|
||||
const i = light("inputColor");
|
||||
const inputBorder = light("inputBorderColor");
|
||||
|
||||
return {
|
||||
...(q && !saved.elementHeadlineColor && { elementHeadlineColor: { light: q } }),
|
||||
...(q && !saved.elementDescriptionColor && { elementDescriptionColor: { light: q } }),
|
||||
...(q && !saved.elementUpperLabelColor && { elementUpperLabelColor: { light: q } }),
|
||||
...(q && !saved.inputTextColor && { inputTextColor: { light: q } }),
|
||||
...(q && !saved.optionLabelColor && { optionLabelColor: { light: q } }),
|
||||
...(b && !saved.buttonBgColor && { buttonBgColor: { light: b } }),
|
||||
...(b && !saved.buttonTextColor && { buttonTextColor: { light: isLight(b) ? "#0f172a" : "#ffffff" } }),
|
||||
...(i && !saved.optionBgColor && { optionBgColor: { light: i } }),
|
||||
...(inputBorder && !saved.optionBorderColor && { optionBorderColor: { light: inputBorder } }),
|
||||
...(b && !saved.progressIndicatorBgColor && { progressIndicatorBgColor: { light: b } }),
|
||||
...(b && !saved.progressTrackBgColor && { progressTrackBgColor: { light: mixColor(b, "#ffffff", 0.8) } }),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a complete TProjectStyling object from a single brand color.
|
||||
*
|
||||
@@ -175,6 +214,7 @@ export const buildStylingFromBrandColor = (brandColor: string = DEFAULT_BRAND_CO
|
||||
inputTextColor: { light: colors["inputTextColor.light"] },
|
||||
optionBgColor: { light: colors["optionBgColor.light"] },
|
||||
optionLabelColor: { light: colors["optionLabelColor.light"] },
|
||||
optionBorderColor: { light: colors["optionBorderColor.light"] },
|
||||
cardBackgroundColor: { light: colors["cardBackgroundColor.light"] },
|
||||
cardBorderColor: { light: colors["cardBorderColor.light"] },
|
||||
highlightBorderColor: { light: colors["highlightBorderColor.light"] },
|
||||
|
||||
@@ -168,6 +168,7 @@ export const mockContactAttributeKey: TContactAttributeKey = {
|
||||
type: "custom",
|
||||
description: "mock action class",
|
||||
isUnique: false,
|
||||
dataType: "string",
|
||||
...commonMockProperties,
|
||||
};
|
||||
|
||||
|
||||
@@ -508,6 +508,7 @@ export const updateSurveyInternal = async (
|
||||
newFollowUps.length > 0
|
||||
? {
|
||||
data: newFollowUps.map((followUp) => ({
|
||||
id: followUp.id,
|
||||
name: followUp.name,
|
||||
trigger: followUp.trigger,
|
||||
action: followUp.action,
|
||||
|
||||
@@ -142,7 +142,8 @@ describe("Time Utilities", () => {
|
||||
expect(convertDatesInObject(123)).toBe(123);
|
||||
});
|
||||
|
||||
test("should not convert dates in contactAttributes", () => {
|
||||
test("should not convert dates in ignored keys when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
@@ -151,13 +152,14 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.contactAttributes.email).toBe("test@example.com");
|
||||
});
|
||||
|
||||
test("should not convert dates in variables", () => {
|
||||
test("should not convert dates in variables when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
updatedAt: "2024-03-20T15:30:00",
|
||||
variables: {
|
||||
@@ -166,13 +168,14 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.updatedAt).toBeInstanceOf(Date);
|
||||
expect(result.variables.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.variables.userId).toBe("123");
|
||||
});
|
||||
|
||||
test("should not convert dates in data or meta", () => {
|
||||
test("should not convert dates in data or meta when keysToIgnore is provided", () => {
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
data: {
|
||||
@@ -183,10 +186,23 @@ describe("Time Utilities", () => {
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
const result = convertDatesInObject(input, keysToIgnore);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.data.createdAt).toBe("2024-03-20T16:30:00");
|
||||
expect(result.meta.updatedAt).toBe("2024-03-20T17:30:00");
|
||||
});
|
||||
|
||||
test("should recurse into all keys when keysToIgnore is not provided", () => {
|
||||
const input = {
|
||||
createdAt: "2024-03-20T15:30:00",
|
||||
contactAttributes: {
|
||||
createdAt: "2024-03-20T16:30:00",
|
||||
},
|
||||
};
|
||||
|
||||
const result = convertDatesInObject(input);
|
||||
expect(result.createdAt).toBeInstanceOf(Date);
|
||||
expect(result.contactAttributes.createdAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -151,18 +151,17 @@ export const getTodaysDateTimeFormatted = (seperator: string) => {
|
||||
return [formattedDate, formattedTime].join(seperator);
|
||||
};
|
||||
|
||||
export const convertDatesInObject = <T>(obj: T): T => {
|
||||
export const convertDatesInObject = <T>(obj: T, keysToIgnore?: Set<string>): T => {
|
||||
if (obj === null || typeof obj !== "object") {
|
||||
return obj; // Return if obj is not an object
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
// Handle arrays by mapping each element through the function
|
||||
return obj.map((item) => convertDatesInObject(item)) as unknown as T;
|
||||
return obj.map((item) => convertDatesInObject(item, keysToIgnore)) as unknown as T;
|
||||
}
|
||||
const newObj: any = {};
|
||||
const keysToIgnore = new Set(["contactAttributes", "variables", "data", "meta"]);
|
||||
const newObj: Record<string, unknown> = {};
|
||||
for (const key in obj) {
|
||||
if (keysToIgnore.has(key)) {
|
||||
if (keysToIgnore?.has(key)) {
|
||||
newObj[key] = obj[key];
|
||||
continue;
|
||||
}
|
||||
@@ -173,10 +172,10 @@ export const convertDatesInObject = <T>(obj: T): T => {
|
||||
) {
|
||||
newObj[key] = new Date(obj[key] as unknown as string);
|
||||
} else if (typeof obj[key] === "object" && obj[key] !== null) {
|
||||
newObj[key] = convertDatesInObject(obj[key]);
|
||||
newObj[key] = convertDatesInObject(obj[key], keysToIgnore);
|
||||
} else {
|
||||
newObj[key] = obj[key];
|
||||
}
|
||||
}
|
||||
return newObj;
|
||||
return newObj as T;
|
||||
};
|
||||
|
||||
@@ -11,3 +11,16 @@ export const isSafeIdentifier = (value: string): boolean => {
|
||||
// Can only contain lowercase letters, numbers, and underscores
|
||||
return /^[a-z0-9_]+$/.test(value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a snake_case string to Title Case for display as a label.
|
||||
* Example: "job_description" -> "Job Description"
|
||||
* "api_key" -> "Api Key"
|
||||
* "signup_date" -> "Signup Date"
|
||||
*/
|
||||
export const formatSnakeCaseToTitleCase = (key: string): string => {
|
||||
return key
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
};
|
||||
|
||||
@@ -12,11 +12,18 @@ export function validateInputs<T extends ValidationPair<any>[]>(
|
||||
for (const [value, schema] of pairs) {
|
||||
const inputValidation = schema.safeParse(value);
|
||||
if (!inputValidation.success) {
|
||||
const zodDetails = inputValidation.error.issues
|
||||
.map((issue) => {
|
||||
const path = issue?.path?.join(".") ?? "";
|
||||
return `${path}${issue.message}`;
|
||||
})
|
||||
.join("; ");
|
||||
|
||||
logger.error(
|
||||
inputValidation.error,
|
||||
`Validation failed for ${JSON.stringify(value).substring(0, 100)} and ${JSON.stringify(schema)}`
|
||||
);
|
||||
throw new ValidationError("Validation failed");
|
||||
throw new ValidationError(`Validation failed: ${zodDetails}`);
|
||||
}
|
||||
parsedData.push(inputValidation.data);
|
||||
}
|
||||
|
||||
@@ -175,9 +175,11 @@
|
||||
"copy": "Kopieren",
|
||||
"copy_code": "Code kopieren",
|
||||
"copy_link": "Link kopieren",
|
||||
"count_attributes": "{value, plural, one {{value} Attribut} other {{value} Attribute}}",
|
||||
"count_contacts": "{value, plural, one {{value} Kontakt} other {{value} Kontakte}}",
|
||||
"count_responses": "{value, plural, one {{value} Antwort} other {{value} Antworten}}",
|
||||
"count_attributes": "{count, plural, one {{count} Attribut} other {{count} Attribute}}",
|
||||
"count_contacts": "{count, plural, one {{count} Kontakt} other {{count} Kontakte}}",
|
||||
"count_members": "{count, plural, one {{count} Mitglied} other {{count} Mitglieder}}",
|
||||
"count_responses": "{count, plural, one {{count} Antwort} other {{count} Antworten}}",
|
||||
"count_selections": "{count, plural, one {{count} Auswahl} other {{count} Auswahlen}}",
|
||||
"create_new_organization": "Neue Organisation erstellen",
|
||||
"create_segment": "Segment erstellen",
|
||||
"create_survey": "Umfrage erstellen",
|
||||
@@ -188,8 +190,10 @@
|
||||
"customer_success": "Kundenerfolg",
|
||||
"dark_overlay": "Dunkle Überlagerung",
|
||||
"date": "Datum",
|
||||
"days": "Tage",
|
||||
"default": "Standard",
|
||||
"delete": "Löschen",
|
||||
"delete_what": "{deleteWhat} löschen",
|
||||
"description": "Beschreibung",
|
||||
"dev_env": "Entwicklungsumgebung",
|
||||
"development": "Entwicklung",
|
||||
@@ -217,13 +221,16 @@
|
||||
"error": "Fehler",
|
||||
"error_component_description": "Diese Ressource existiert nicht oder Du hast nicht die notwendigen Rechte, um darauf zuzugreifen.",
|
||||
"error_component_title": "Fehler beim Laden der Ressourcen",
|
||||
"error_loading_data": "Fehler beim Laden der Daten",
|
||||
"error_rate_limit_description": "Maximale Anzahl an Anfragen erreicht. Bitte später erneut versuchen.",
|
||||
"error_rate_limit_title": "Rate Limit Überschritten",
|
||||
"expand_rows": "Zeilen erweitern",
|
||||
"failed_to_copy_to_clipboard": "Fehler beim Kopieren in die Zwischenablage",
|
||||
"failed_to_load_organizations": "Fehler beim Laden der Organisationen",
|
||||
"failed_to_load_workspaces": "Projekte konnten nicht geladen werden",
|
||||
"filter": "Filter",
|
||||
"finish": "Fertigstellen",
|
||||
"first_name": "Vorname",
|
||||
"follow_these": "Folge diesen",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Name",
|
||||
@@ -236,6 +243,7 @@
|
||||
"hidden_field": "Verstecktes Feld",
|
||||
"hidden_fields": "Versteckte Felder",
|
||||
"hide_column": "Spalte ausblenden",
|
||||
"id": "ID",
|
||||
"image": "Bild",
|
||||
"images": "Bilder",
|
||||
"import": "Importieren",
|
||||
@@ -253,6 +261,7 @@
|
||||
"key": "Schlüssel",
|
||||
"label": "Bezeichnung",
|
||||
"language": "Sprache",
|
||||
"last_name": "Nachname",
|
||||
"learn_more": "Mehr erfahren",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Helle Überlagerung",
|
||||
@@ -267,7 +276,6 @@
|
||||
"look_and_feel": "Darstellung",
|
||||
"manage": "Verwalten",
|
||||
"marketing": "Marketing",
|
||||
"member": "Mitglied",
|
||||
"members": "Mitglieder",
|
||||
"members_and_teams": "Mitglieder & Teams",
|
||||
"membership_not_found": "Mitgliedschaft nicht gefunden",
|
||||
@@ -275,9 +283,11 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks funktioniert am besten auf einem größeren Bildschirm. Um Umfragen zu verwalten oder zu erstellen, wechsle zu einem anderen Gerät.",
|
||||
"mobile_overlay_surveys_look_good": "Keine Sorge – deine Umfragen sehen auf jedem Gerät und jeder Bildschirmgröße großartig aus!",
|
||||
"mobile_overlay_title": "Oops, Bildschirm zu klein erkannt!",
|
||||
"months": "Monate",
|
||||
"move_down": "Nach unten bewegen",
|
||||
"move_up": "Nach oben bewegen",
|
||||
"multiple_languages": "Mehrsprachigkeit",
|
||||
"my_product": "mein Produkt",
|
||||
"name": "Name",
|
||||
"new": "Neu",
|
||||
"new_version_available": "Formbricks {version} ist da. Jetzt aktualisieren!",
|
||||
@@ -373,8 +383,6 @@
|
||||
"select_teams": "Teams auswählen",
|
||||
"selected": "Ausgewählt",
|
||||
"selected_questions": "Ausgewählte Fragen",
|
||||
"selection": "Auswahl",
|
||||
"selections": "Auswahlen",
|
||||
"send_test_email": "Test-E-Mail senden",
|
||||
"session_not_found": "Sitzung nicht gefunden",
|
||||
"settings": "Einstellungen",
|
||||
@@ -393,6 +401,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Schritt-für-Schritt-Anleitung",
|
||||
"storage_not_configured": "Dateispeicher nicht eingerichtet, Uploads werden wahrscheinlich fehlschlagen",
|
||||
"string": "Text",
|
||||
"styling": "Styling",
|
||||
"submit": "Abschicken",
|
||||
"summary": "Zusammenfassung",
|
||||
@@ -425,6 +434,7 @@
|
||||
"top_right": "Oben rechts",
|
||||
"try_again": "Versuch's nochmal",
|
||||
"type": "Typ",
|
||||
"unknown_survey": "Unbekannte Umfrage",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Schalten Sie mehr Projekte mit einem höheren Tarif frei.",
|
||||
"update": "Aktualisierung",
|
||||
"updated": "Aktualisiert",
|
||||
@@ -448,6 +458,7 @@
|
||||
"website_and_app_connection": "Website & App Verbindung",
|
||||
"website_app_survey": "Website- & App-Umfrage",
|
||||
"website_survey": "Website-Umfrage",
|
||||
"weeks": "Wochen",
|
||||
"welcome_card": "Willkommenskarte",
|
||||
"workspace_configuration": "Projektkonfiguration",
|
||||
"workspace_created_successfully": "Projekt erfolgreich erstellt",
|
||||
@@ -458,6 +469,7 @@
|
||||
"workspace_not_found": "Projekt nicht gefunden",
|
||||
"workspace_permission_not_found": "Projektberechtigung nicht gefunden",
|
||||
"workspaces": "Projekte",
|
||||
"years": "Jahre",
|
||||
"you": "Du",
|
||||
"you_are_downgraded_to_the_community_edition": "Du wurdest auf die Community Edition herabgestuft.",
|
||||
"you_are_not_authorized_to_perform_this_action": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
|
||||
@@ -629,28 +641,45 @@
|
||||
"attribute_updated_successfully": "Attribut erfolgreich aktualisiert",
|
||||
"attribute_value": "Wert",
|
||||
"attribute_value_placeholder": "Attributwert",
|
||||
"attributes_msg_attribute_limit_exceeded": "Es konnten {count} neue Attribute nicht erstellt werden, da dies das maximale Limit von {limit} Attributklassen überschreiten würde. Bestehende Attribute wurden erfolgreich aktualisiert.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (Attribut '{key}' hat dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "Die E-Mail existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"attributes_msg_email_or_userid_required": "Entweder E-Mail oder userId ist erforderlich. Die bestehenden Werte wurden beibehalten.",
|
||||
"attributes_msg_new_attribute_created": "Neues Attribut '{key}' mit Typ '{dataType}' erstellt",
|
||||
"attributes_msg_userid_already_exists": "Die userId existiert bereits für diese Umgebung und wurde nicht aktualisiert.",
|
||||
"contact_deleted_successfully": "Kontakt erfolgreich gelöscht",
|
||||
"contact_not_found": "Kein solcher Kontakt gefunden",
|
||||
"contacts_table_refresh": "Kontakte aktualisieren",
|
||||
"contacts_table_refresh_success": "Kontakte erfolgreich aktualisiert",
|
||||
"create_attribute": "Attribut erstellen",
|
||||
"create_key": "Schlüssel erstellen",
|
||||
"create_new_attribute": "Neues Attribut erstellen",
|
||||
"create_new_attribute_description": "Erstellen Sie ein neues Attribut für Segmentierungszwecke.",
|
||||
"custom_attributes": "Benutzerdefinierte Attribute",
|
||||
"data_type": "Datentyp",
|
||||
"data_type_cannot_be_changed": "Der Datentyp kann nach der Erstellung nicht mehr geändert werden",
|
||||
"data_type_description": "Wähle aus, wie dieses Attribut gespeichert und gefiltert werden soll",
|
||||
"date_value_required": "Ein Datumswert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen, wenn du kein Datum festlegen möchtest.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {Dadurch wird das ausgewählte Attribut gelöscht. Alle mit diesem Attribut verknüpften Kontaktdaten gehen verloren.} other {Dadurch werden die ausgewählten Attribute gelöscht. Alle mit diesen Attributen verknüpften Kontaktdaten gehen verloren.}}",
|
||||
"delete_contact_confirmation": "Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesem Kontakt verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn dieser Kontakt Antworten hat, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.} other {Dies wird alle Umfrageantworten und Kontaktattribute löschen, die mit diesen Kontakten verbunden sind. Jegliche zielgerichtete Kommunikation und Personalisierung basierend auf den Daten dieses Kontakts gehen verloren. Wenn diesen Kontakten Antworten haben, die zu den Umfragequoten zählen, werden die Quotenstände reduziert, aber die Quotenlimits bleiben unverändert.}}",
|
||||
"displays": "Anzeigen",
|
||||
"edit_attribute": "Attribut bearbeiten",
|
||||
"edit_attribute_description": "Aktualisieren Sie die Bezeichnung und Beschreibung für dieses Attribut.",
|
||||
"edit_attribute_values": "Attribute bearbeiten",
|
||||
"edit_attribute_values_description": "Ändern Sie die Werte für bestimmte Attribute dieses Kontakts.",
|
||||
"edit_attributes": "Attribute bearbeiten",
|
||||
"edit_attributes_success": "Kontaktattribute erfolgreich aktualisiert",
|
||||
"generate_personal_link": "Persönlichen Link generieren",
|
||||
"generate_personal_link_description": "Wähle eine veröffentlichte Umfrage aus, um einen personalisierten Link für diesen Kontakt zu generieren.",
|
||||
"invalid_csv_column_names": "Ungültige CSV-Spaltennamen: {columns}. Spaltennamen, die zu neuen Attributen werden, dürfen nur Kleinbuchstaben, Zahlen und Unterstriche enthalten und müssen mit einem Buchstaben beginnen.",
|
||||
"invalid_date_format": "Ungültiges Datumsformat. Bitte verwende ein gültiges Datum.",
|
||||
"invalid_number_format": "Ungültiges Zahlenformat. Bitte gib eine gültige Zahl ein.",
|
||||
"no_activity_yet": "Noch keine Aktivität",
|
||||
"no_published_link_surveys_available": "Keine veröffentlichten Link-Umfragen verfügbar. Bitte veröffentliche zuerst eine Link-Umfrage.",
|
||||
"no_published_surveys": "Keine veröffentlichten Umfragen",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"not_provided": "Nicht angegeben",
|
||||
"number_value_required": "Zahlenwert ist erforderlich. Verwende die Löschen-Schaltfläche, um dieses Attribut zu entfernen.",
|
||||
"personal_link_generated": "Persönlicher Link erfolgreich generiert",
|
||||
"personal_link_generated_but_clipboard_failed": "Persönlicher Link wurde generiert, konnte aber nicht in die Zwischenablage kopiert werden: {url}",
|
||||
"personal_survey_link": "Link zur persönlichen Umfrage",
|
||||
@@ -659,13 +688,24 @@
|
||||
"search_contact": "Kontakt suchen",
|
||||
"select_a_survey": "Wähle eine Umfrage aus",
|
||||
"select_attribute": "Attribut auswählen",
|
||||
"select_attribute_key": "Attributschlüssel auswählen",
|
||||
"survey_viewed": "Umfrage angesehen",
|
||||
"survey_viewed_at": "Angesehen am",
|
||||
"system_attributes": "Systemattribute",
|
||||
"unlock_contacts_description": "Verwalte Kontakte und sende gezielte Umfragen",
|
||||
"unlock_contacts_title": "Kontakte mit einem höheren Plan freischalten",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribut \"{key}\" ist als \"{dataType}\" definiert, aber die CSV-Datei enthält ungültige Werte: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Doppelte Zuordnungen für folgende Attribute gefunden: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "Dateigröße überschreitet das maximale Limit von 800KB",
|
||||
"upload_contacts_error_generic": "Beim Hochladen der Kontakte ist ein Fehler aufgetreten. Bitte versuche es später erneut.",
|
||||
"upload_contacts_error_invalid_file_type": "Bitte lade eine CSV-Datei hoch",
|
||||
"upload_contacts_error_no_valid_contacts": "Die hochgeladene CSV-Datei enthält keine gültigen Kontakte. Bitte schaue dir die Beispiel-CSV-Datei für das richtige Format an.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks-Attribut",
|
||||
"upload_contacts_modal_attributes_description": "Ordne die Spalten in deiner CSV den Attributen in Formbricks zu.",
|
||||
"upload_contacts_modal_attributes_new": "Neues Attribut",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Attribut suchen oder hinzufügen",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "sollte zugeordnet werden zu",
|
||||
"upload_contacts_modal_attributes_title": "Attribute",
|
||||
"upload_contacts_modal_csv_column_header": "CSV-Spalte",
|
||||
"upload_contacts_modal_description": "Lade eine CSV hoch, um Kontakte mit Attributen schnell zu importieren",
|
||||
"upload_contacts_modal_download_example_csv": "Beispiel-CSV herunterladen",
|
||||
"upload_contacts_modal_duplicates_description": "Wie sollen wir vorgehen, wenn ein Kontakt bereits existiert?",
|
||||
@@ -722,7 +762,12 @@
|
||||
"link_google_sheet": "Tabelle verlinken",
|
||||
"link_new_sheet": "Neues Blatt verknüpfen",
|
||||
"no_integrations_yet": "Deine verknüpften Tabellen werden hier angezeigt, sobald Du sie hinzufügst ⏲️",
|
||||
"spreadsheet_url": "Tabellen-URL"
|
||||
"reconnect_button": "Erneut verbinden",
|
||||
"reconnect_button_description": "Deine Google Sheets-Verbindung ist abgelaufen. Bitte verbinde dich erneut, um weiterhin Antworten zu synchronisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
|
||||
"reconnect_button_tooltip": "Verbinde die Integration erneut, um deinen Zugriff zu aktualisieren. Deine bestehenden Tabellen-Links und Daten bleiben erhalten.",
|
||||
"spreadsheet_permission_error": "Du hast keine Berechtigung, auf diese Tabelle zuzugreifen. Bitte stelle sicher, dass die Tabelle mit deinem Google-Konto geteilt ist und du Schreibzugriff auf die Tabelle hast.",
|
||||
"spreadsheet_url": "Tabellen-URL",
|
||||
"token_expired_error": "Das Google Sheets-Aktualisierungstoken ist abgelaufen oder wurde widerrufen. Bitte verbinde die Integration erneut."
|
||||
},
|
||||
"include_created_at": "Erstellungsdatum einbeziehen",
|
||||
"include_hidden_fields": "Versteckte Felder (hidden fields) einbeziehen",
|
||||
@@ -846,6 +891,40 @@
|
||||
"no_attributes_yet": "Noch keine Attribute",
|
||||
"no_filters_yet": "Es gibt noch keine Filter",
|
||||
"no_segments_yet": "Du hast momentan keine gespeicherten Segmente.",
|
||||
"operator_contains": "enthält",
|
||||
"operator_does_not_contain": "enthält nicht",
|
||||
"operator_ends_with": "endet mit",
|
||||
"operator_is_after": "ist nach",
|
||||
"operator_is_before": "ist vor",
|
||||
"operator_is_between": "ist zwischen",
|
||||
"operator_is_newer_than": "ist neuer als",
|
||||
"operator_is_not_set": "ist nicht festgelegt",
|
||||
"operator_is_older_than": "ist älter als",
|
||||
"operator_is_same_day": "ist am selben Tag",
|
||||
"operator_is_set": "ist festgelegt",
|
||||
"operator_starts_with": "fängt an mit",
|
||||
"operator_title_contains": "Enthält",
|
||||
"operator_title_does_not_contain": "Enthält nicht",
|
||||
"operator_title_ends_with": "Endet mit",
|
||||
"operator_title_equals": "Gleich",
|
||||
"operator_title_greater_equal": "Größer als oder gleich",
|
||||
"operator_title_greater_than": "Größer als",
|
||||
"operator_title_is_after": "Ist nach",
|
||||
"operator_title_is_before": "Ist vor",
|
||||
"operator_title_is_between": "Ist zwischen",
|
||||
"operator_title_is_newer_than": "Ist neuer als",
|
||||
"operator_title_is_not_set": "Ist nicht festgelegt",
|
||||
"operator_title_is_older_than": "Ist älter als",
|
||||
"operator_title_is_same_day": "Ist am selben Tag",
|
||||
"operator_title_is_set": "Ist festgelegt",
|
||||
"operator_title_less_equal": "Kleiner oder gleich",
|
||||
"operator_title_less_than": "Kleiner als",
|
||||
"operator_title_not_equals": "Ist nicht gleich",
|
||||
"operator_title_starts_with": "Fängt an mit",
|
||||
"operator_title_user_is_in": "Nutzer ist in",
|
||||
"operator_title_user_is_not_in": "Nutzer ist nicht in",
|
||||
"operator_user_is_in": "Nutzer ist in",
|
||||
"operator_user_is_not_in": "Nutzer ist nicht in",
|
||||
"person_and_attributes": "Person & Attribute",
|
||||
"phone": "Handy",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Bitte entferne das Segment aus diesen Umfragen, um es zu löschen.",
|
||||
@@ -870,6 +949,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "Benutzerzielgruppen sind derzeit nur verfügbar, wenn",
|
||||
"value_cannot_be_empty": "Wert darf nicht leer sein.",
|
||||
"value_must_be_a_number": "Wert muss eine Zahl sein.",
|
||||
"value_must_be_positive": "Wert muss eine positive Zahl sein.",
|
||||
"view_filters": "Filter anzeigen",
|
||||
"where": "Wo",
|
||||
"with_the_formbricks_sdk": "mit dem Formbricks SDK"
|
||||
@@ -1002,7 +1082,7 @@
|
||||
"email_customization_preview_email_heading": "Hey {userName}",
|
||||
"email_customization_preview_email_text": "Dies ist eine E-Mail-Vorschau, um dir zu zeigen, welches Logo in den E-Mails gerendert wird.",
|
||||
"error_deleting_organization_please_try_again": "Fehler beim Löschen der Organisation. Bitte versuche es erneut.",
|
||||
"from_your_organization": "von deiner Organisation",
|
||||
"from_your_organization": "{memberName} aus Ihrer Organisation",
|
||||
"invitation_sent_once_more": "Einladung nochmal gesendet.",
|
||||
"invite_deleted_successfully": "Einladung erfolgreich gelöscht",
|
||||
"invite_expires_on": "Einladung läuft ab am {date}",
|
||||
@@ -1167,6 +1247,7 @@
|
||||
"add_fallback_placeholder": "Platzhalter hinzufügen, falls kein Wert zur Verfügung steht.",
|
||||
"add_hidden_field_id": "Verstecktes Feld ID hinzufügen",
|
||||
"add_highlight_border": "Rahmen hinzufügen",
|
||||
"add_highlight_border_description": "Gilt nur für In-Product-Umfragen.",
|
||||
"add_logic": "Logik hinzufügen",
|
||||
"add_none_of_the_above": "Füge \"Keine der oben genannten Optionen\" hinzu",
|
||||
"add_option": "Option hinzufügen",
|
||||
@@ -1365,7 +1446,6 @@
|
||||
"follow_ups_modal_updated_successfull_toast": "Nachverfolgung aktualisiert und wird gespeichert, sobald du die Umfrage speicherst.",
|
||||
"follow_ups_new": "Neues Follow-up",
|
||||
"follow_ups_upgrade_button_text": "Upgrade, um Follow-ups zu aktivieren",
|
||||
"form_styling": "Umfrage Styling",
|
||||
"formbricks_sdk_is_not_connected": "Formbricks SDK ist nicht verbunden",
|
||||
"four_points": "4 Punkte",
|
||||
"heading": "Überschrift",
|
||||
@@ -1538,7 +1618,7 @@
|
||||
"response_limits_redirections_and_more": "Antwort Limits, Weiterleitungen und mehr.",
|
||||
"response_options": "Antwortoptionen",
|
||||
"roundness": "Rundheit",
|
||||
"roundness_description": "Steuert, wie abgerundet die Kartenecken sind.",
|
||||
"roundness_description": "Steuert, wie abgerundet die Ecken sind.",
|
||||
"row_used_in_logic_error": "Diese Zeile wird in der Logik der Frage {questionIndex} verwendet. Bitte entferne sie zuerst aus der Logik.",
|
||||
"rows": "Zeilen",
|
||||
"save_and_close": "Speichern & Schließen",
|
||||
@@ -1584,6 +1664,7 @@
|
||||
"survey_completed_subheading": "Diese kostenlose und quelloffene Umfrage wurde geschlossen",
|
||||
"survey_display_settings": "Einstellungen zur Anzeige der Umfrage",
|
||||
"survey_placement": "Platzierung der Umfrage",
|
||||
"survey_styling": "Umfrage Styling",
|
||||
"survey_trigger": "Auslöser der Umfrage",
|
||||
"switch_multi_language_on_to_get_started": "Aktiviere Mehrsprachigkeit, um loszulegen 👉",
|
||||
"target_block_not_found": "Zielblock nicht gefunden",
|
||||
@@ -1674,7 +1755,6 @@
|
||||
"welcome_message": "Willkommensnachricht",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Ohne Filter können alle deine Nutzer befragt werden.",
|
||||
"you_have_not_created_a_segment_yet": "Du hast noch keinen Segment erstellt.",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "Sie müssen zwei oder mehr Sprachen in Ihrem Workspace eingerichtet haben, um mit Übersetzungen zu arbeiten.",
|
||||
"your_description_here_recall_information_with": "Deine Beschreibung hier. Informationen abrufen mit @",
|
||||
"your_question_here_recall_information_with": "Deine Frage hier. Informationen abrufen mit @",
|
||||
"your_web_app": "Deine Web-App",
|
||||
@@ -1882,6 +1962,7 @@
|
||||
"filtered_responses_excel": "Gefilterte Antworten (Excel)",
|
||||
"generating_qr_code": "QR-Code wird generiert",
|
||||
"impressions": "Eindrücke",
|
||||
"impressions_identified_only": "Zeigt nur Impressionen von identifizierten Kontakten",
|
||||
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
|
||||
"in_app": {
|
||||
"connection_description": "Die Umfrage wird den Nutzern Ihrer Website angezeigt, die den unten aufgeführten Kriterien entsprechen",
|
||||
@@ -1924,6 +2005,7 @@
|
||||
"last_quarter": "Letztes Quartal",
|
||||
"last_year": "Letztes Jahr",
|
||||
"limit": "Limit",
|
||||
"no_identified_impressions": "Keine Impressionen von identifizierten Kontakten",
|
||||
"no_responses_found": "Keine Antworten gefunden",
|
||||
"other_values_found": "Andere Werte gefunden",
|
||||
"overall": "Insgesamt",
|
||||
@@ -1946,6 +2028,7 @@
|
||||
"starts": "Startet",
|
||||
"starts_tooltip": "So oft wurde die Umfrage gestartet.",
|
||||
"survey_reset_successfully": "Umfrage erfolgreich zurückgesetzt! {responseCount} Antworten und {displayCount} Anzeigen wurden gelöscht.",
|
||||
"survey_results": "{surveyName}-Ergebnisse",
|
||||
"this_month": "Dieser Monat",
|
||||
"this_quarter": "Dieses Quartal",
|
||||
"this_year": "Dieses Jahr",
|
||||
@@ -2088,12 +2171,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Skaliert den Überschriftentext.",
|
||||
"advanced_styling_field_headline_weight": "Schriftstärke der Überschrift",
|
||||
"advanced_styling_field_headline_weight_description": "Macht den Überschriftentext heller oder fetter.",
|
||||
"advanced_styling_field_height": "Höhe",
|
||||
"advanced_styling_field_height": "Mindesthöhe",
|
||||
"advanced_styling_field_indicator_bg": "Indikator-Hintergrund",
|
||||
"advanced_styling_field_indicator_bg_description": "Färbt den gefüllten Teil des Balkens.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rundet die Eingabeecken ab.",
|
||||
"advanced_styling_field_input_font_size_description": "Skaliert den eingegebenen Text in Eingabefeldern.",
|
||||
"advanced_styling_field_input_height_description": "Steuert die Höhe des Eingabefelds.",
|
||||
"advanced_styling_field_input_height_description": "Steuert die Mindesthöhe der Eingabe.",
|
||||
"advanced_styling_field_input_padding_x_description": "Fügt links und rechts Abstand hinzu.",
|
||||
"advanced_styling_field_input_padding_y_description": "Fügt oben und unten Abstand hinzu.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Blendet den Platzhaltertext aus.",
|
||||
@@ -2102,6 +2185,8 @@
|
||||
"advanced_styling_field_input_text_description": "Färbt den eingegebenen Text in Eingabefeldern.",
|
||||
"advanced_styling_field_option_bg": "Hintergrund",
|
||||
"advanced_styling_field_option_bg_description": "Füllt die Optionselemente.",
|
||||
"advanced_styling_field_option_border": "Rahmenfarbe",
|
||||
"advanced_styling_field_option_border_description": "Umrandet Radio- und Checkbox-Optionen.",
|
||||
"advanced_styling_field_option_border_radius_description": "Rundet die Ecken der Optionen ab.",
|
||||
"advanced_styling_field_option_font_size_description": "Skaliert den Text der Optionsbeschriftung.",
|
||||
"advanced_styling_field_option_label": "Label-Farbe",
|
||||
@@ -2156,6 +2241,7 @@
|
||||
"show_powered_by_formbricks": "\"Powered by Formbricks\"-Signatur anzeigen",
|
||||
"styling_updated_successfully": "Styling erfolgreich aktualisiert",
|
||||
"suggest_colors": "Farben vorschlagen",
|
||||
"suggested_colors_applied_please_save": "Vorgeschlagene Farben erfolgreich generiert. Drücke \"Speichern\", um die Änderungen zu übernehmen.",
|
||||
"theme": "Theme",
|
||||
"theme_settings_description": "Erstelle ein Style-Theme für alle Umfragen. Du kannst für jede Umfrage individuelles Styling aktivieren."
|
||||
},
|
||||
@@ -2920,6 +3006,9 @@
|
||||
"preview_survey_question_2_choice_2_label": "Nein, danke!",
|
||||
"preview_survey_question_2_headline": "Möchtest Du auf dem Laufenden bleiben?",
|
||||
"preview_survey_question_2_subheader": "Dies ist eine Beispielbeschreibung.",
|
||||
"preview_survey_question_open_text_headline": "Möchtest Du noch etwas teilen?",
|
||||
"preview_survey_question_open_text_placeholder": "Tippe deine Antwort hier...",
|
||||
"preview_survey_question_open_text_subheader": "Dein Feedback hilft uns, besser zu werden.",
|
||||
"preview_survey_welcome_card_headline": "Willkommen!",
|
||||
"prioritize_features_description": "Identifiziere die Funktionen, die deine Nutzer am meisten und am wenigsten brauchen.",
|
||||
"prioritize_features_name": "Funktionen priorisieren",
|
||||
|
||||
@@ -39,14 +39,14 @@
|
||||
},
|
||||
"invite": {
|
||||
"create_account": "Create an account",
|
||||
"email_does_not_match": "Ooops! Wrong email \uD83E\uDD26",
|
||||
"email_does_not_match": "Ooops! Wrong email 🤦",
|
||||
"email_does_not_match_description": "The email in the invitation does not match yours.",
|
||||
"go_to_app": "Go to app",
|
||||
"happy_to_have_you": "Happy to have you \uD83E\uDD17",
|
||||
"happy_to_have_you": "Happy to have you 🤗",
|
||||
"happy_to_have_you_description": "Please create an account or login.",
|
||||
"invite_expired": "Invite expired \uD83D\uDE25",
|
||||
"invite_expired": "Invite expired 😥",
|
||||
"invite_expired_description": "Invites are valid for 7 days. Please request a new invite.",
|
||||
"invite_not_found": "Invite not found \uD83D\uDE25",
|
||||
"invite_not_found": "Invite not found 😥",
|
||||
"invite_not_found_description": "The invitation code cannot be found or has already been used.",
|
||||
"login": "Login",
|
||||
"welcome_to_organization": "You are in \uD83C\uDF89",
|
||||
@@ -175,9 +175,11 @@
|
||||
"copy": "Copy",
|
||||
"copy_code": "Copy code",
|
||||
"copy_link": "Copy Link",
|
||||
"count_attributes": "{value, plural, one {{value} attribute} other {{value} attributes}}",
|
||||
"count_contacts": "{value, plural, one {{value} contact} other {{value} contacts}}",
|
||||
"count_responses": "{value, plural, one {{value} response} other {{value} responses}}",
|
||||
"count_attributes": "{count, plural, one {{count} attribute} other {{count} attributes}}",
|
||||
"count_contacts": "{count, plural, one {{count} contact} other {{count} contacts}}",
|
||||
"count_members": "{count, plural, one {{count} member} other {{count} members}}",
|
||||
"count_responses": "{count, plural, one {{count} response} other {{count} responses}}",
|
||||
"count_selections": "{count, plural, one {{count} selection} other {{count} selections}}",
|
||||
"create_new_organization": "Create new organization",
|
||||
"create_segment": "Create segment",
|
||||
"create_survey": "Create survey",
|
||||
@@ -188,8 +190,10 @@
|
||||
"customer_success": "Customer Success",
|
||||
"dark_overlay": "Dark overlay",
|
||||
"date": "Date",
|
||||
"days": "days",
|
||||
"default": "Default",
|
||||
"delete": "Delete",
|
||||
"delete_what": "Delete {deleteWhat}",
|
||||
"description": "Description",
|
||||
"dev_env": "Dev Environment",
|
||||
"development": "Development",
|
||||
@@ -217,13 +221,16 @@
|
||||
"error": "Error",
|
||||
"error_component_description": "This resource does not exist or you do not have the necessary rights to access it.",
|
||||
"error_component_title": "Error loading resources",
|
||||
"error_loading_data": "Error loading data",
|
||||
"error_rate_limit_description": "Maximum number of requests reached. Please try again later.",
|
||||
"error_rate_limit_title": "Rate Limit Exceeded",
|
||||
"expand_rows": "Expand rows",
|
||||
"failed_to_copy_to_clipboard": "Failed to copy to clipboard",
|
||||
"failed_to_load_organizations": "Failed to load organizations",
|
||||
"failed_to_load_workspaces": "Failed to load workspaces",
|
||||
"filter": "Filter",
|
||||
"finish": "Finish",
|
||||
"first_name": "First Name",
|
||||
"follow_these": "Follow these",
|
||||
"formbricks_version": "Formbricks Version",
|
||||
"full_name": "Full name",
|
||||
@@ -236,6 +243,7 @@
|
||||
"hidden_field": "Hidden field",
|
||||
"hidden_fields": "Hidden fields",
|
||||
"hide_column": "Hide column",
|
||||
"id": "ID",
|
||||
"image": "Image",
|
||||
"images": "Images",
|
||||
"import": "Import",
|
||||
@@ -253,6 +261,7 @@
|
||||
"key": "Key",
|
||||
"label": "Label",
|
||||
"language": "Language",
|
||||
"last_name": "Last Name",
|
||||
"learn_more": "Learn more",
|
||||
"license_expired": "License Expired",
|
||||
"light_overlay": "Light overlay",
|
||||
@@ -267,7 +276,6 @@
|
||||
"look_and_feel": "Look & Feel",
|
||||
"manage": "Manage",
|
||||
"marketing": "Marketing",
|
||||
"member": "Member",
|
||||
"members": "Members",
|
||||
"members_and_teams": "Members & Teams",
|
||||
"membership_not_found": "Membership not found",
|
||||
@@ -275,9 +283,11 @@
|
||||
"mobile_overlay_app_works_best_on_desktop": "Formbricks works best on a bigger screen. To manage or build surveys, switch to another device.",
|
||||
"mobile_overlay_surveys_look_good": "Do not worry – your surveys look great on every device and screen size!",
|
||||
"mobile_overlay_title": "Oops, tiny screen detected!",
|
||||
"months": "months",
|
||||
"move_down": "Move down",
|
||||
"move_up": "Move up",
|
||||
"multiple_languages": "Multiple languages",
|
||||
"my_product": "my Product",
|
||||
"name": "Name",
|
||||
"new": "New",
|
||||
"new_version_available": "Formbricks {version} is here. Upgrade now!",
|
||||
@@ -373,8 +383,6 @@
|
||||
"select_teams": "Select teams",
|
||||
"selected": "Selected",
|
||||
"selected_questions": "Selected questions",
|
||||
"selection": "Selection",
|
||||
"selections": "Selections",
|
||||
"send_test_email": "Send test email",
|
||||
"session_not_found": "Session not found",
|
||||
"settings": "Settings",
|
||||
@@ -393,6 +401,7 @@
|
||||
"status": "Status",
|
||||
"step_by_step_manual": "Step by step manual",
|
||||
"storage_not_configured": "File storage not set up, uploads will likely fail",
|
||||
"string": "Text",
|
||||
"styling": "Styling",
|
||||
"submit": "Submit",
|
||||
"summary": "Summary",
|
||||
@@ -425,6 +434,7 @@
|
||||
"top_right": "Top Right",
|
||||
"try_again": "Try again",
|
||||
"type": "Type",
|
||||
"unknown_survey": "Unknown survey",
|
||||
"unlock_more_workspaces_with_a_higher_plan": "Unlock more workspaces with a higher plan.",
|
||||
"update": "Update",
|
||||
"updated": "Updated",
|
||||
@@ -448,6 +458,7 @@
|
||||
"website_and_app_connection": "Website & App Connection",
|
||||
"website_app_survey": "Website & App Survey",
|
||||
"website_survey": "Website Survey",
|
||||
"weeks": "weeks",
|
||||
"welcome_card": "Welcome card",
|
||||
"workspace_configuration": "Workspace Configuration",
|
||||
"workspace_created_successfully": "Workspace created successfully",
|
||||
@@ -458,6 +469,7 @@
|
||||
"workspace_not_found": "Workspace not found",
|
||||
"workspace_permission_not_found": "Workspace permission not found",
|
||||
"workspaces": "Workspaces",
|
||||
"years": "years",
|
||||
"you": "You",
|
||||
"you_are_downgraded_to_the_community_edition": "You are downgraded to the Community Edition.",
|
||||
"you_are_not_authorized_to_perform_this_action": "You are not authorized to perform this action.",
|
||||
@@ -520,7 +532,7 @@
|
||||
"text_variable": "Text variable",
|
||||
"verification_email_click_on_this_link": "You can also click on this link:",
|
||||
"verification_email_heading": "Almost there!",
|
||||
"verification_email_hey": "Hey \uD83D\uDC4B",
|
||||
"verification_email_hey": "Hey 👋",
|
||||
"verification_email_if_expired_request_new_token": "If it has expired please request a new token here:",
|
||||
"verification_email_link_valid_for_24_hours": "The link is valid for 24 hours.",
|
||||
"verification_email_request_new_verification": "Request new verification",
|
||||
@@ -629,28 +641,45 @@
|
||||
"attribute_updated_successfully": "Attribute updated successfully",
|
||||
"attribute_value": "Value",
|
||||
"attribute_value_placeholder": "Attribute Value",
|
||||
"attributes_msg_attribute_limit_exceeded": "Could not create {count} new attribute(s) as it would exceed the maximum limit of {limit} attribute classes. Existing attributes were updated successfully.",
|
||||
"attributes_msg_attribute_type_validation_error": "{error} (attribute '{key}' has dataType: {dataType})",
|
||||
"attributes_msg_email_already_exists": "The email already exists for this environment and was not updated.",
|
||||
"attributes_msg_email_or_userid_required": "Either email or userId is required. The existing values were preserved.",
|
||||
"attributes_msg_new_attribute_created": "Created new attribute '{key}' with type '{dataType}'",
|
||||
"attributes_msg_userid_already_exists": "The userId already exists for this environment and was not updated.",
|
||||
"contact_deleted_successfully": "Contact deleted successfully",
|
||||
"contact_not_found": "No such contact found",
|
||||
"contacts_table_refresh": "Refresh contacts",
|
||||
"contacts_table_refresh_success": "Contacts refreshed successfully",
|
||||
"create_attribute": "Create attribute",
|
||||
"create_key": "Create Key",
|
||||
"create_new_attribute": "Create new attribute",
|
||||
"create_new_attribute_description": "Create a new attribute for segmentation purposes.",
|
||||
"custom_attributes": "Custom Attributes",
|
||||
"data_type": "Data Type",
|
||||
"data_type_cannot_be_changed": "Data type cannot be changed after creation",
|
||||
"data_type_description": "Choose how this attribute should be stored and filtered",
|
||||
"date_value_required": "Date value is required. Use the delete button to remove this attribute if you don't want to set a date.",
|
||||
"delete_attribute_confirmation": "{value, plural, one {This will delete the selected attribute. Any contact data associated with this attribute will be lost.} other {This will delete the selected attributes. Any contact data associated with these attributes will be lost.}}",
|
||||
"delete_contact_confirmation": "This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact’s data will be lost.",
|
||||
"delete_contact_confirmation_with_quotas": "{value, plural, one {This will delete all survey responses and contact attributes associated with this contact. Any targeting and personalization based on this contact’s data will be lost. If this contact has responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.} other {This will delete all survey responses and contact attributes associated with these contacts. Any targeting and personalization based on these contacts’ data will be lost. If these contacts have responses that count towards survey quotas, the quota counts will be reduced but the quota limits will remain unchanged.}}",
|
||||
"displays": "Displays",
|
||||
"edit_attribute": "Edit attribute",
|
||||
"edit_attribute_description": "Update the label and description for this attribute.",
|
||||
"edit_attribute_values": "Edit attributes",
|
||||
"edit_attribute_values_description": "Change the values for specific attributes for this contact.",
|
||||
"edit_attributes": "Edit Attributes",
|
||||
"edit_attributes_success": "Contact attributes updated successfully",
|
||||
"generate_personal_link": "Generate Personal Link",
|
||||
"generate_personal_link_description": "Select a published survey to generate a personalized link for this contact.",
|
||||
"invalid_csv_column_names": "Invalid CSV column name(s): {columns}. Column names that will become new attributes must only contain lowercase letters, numbers, and underscores, and must start with a letter.",
|
||||
"invalid_date_format": "Invalid date format. Please use a valid date.",
|
||||
"invalid_number_format": "Invalid number format. Please enter a valid number.",
|
||||
"no_activity_yet": "No activity yet",
|
||||
"no_published_link_surveys_available": "No published link surveys available. Please publish a link survey first.",
|
||||
"no_published_surveys": "No published surveys",
|
||||
"no_responses_found": "No responses found",
|
||||
"not_provided": "Not provided",
|
||||
"number_value_required": "Number value is required. Use the delete button to remove this attribute.",
|
||||
"personal_link_generated": "Personal link generated successfully",
|
||||
"personal_link_generated_but_clipboard_failed": "Personal link generated but failed to copy to clipboard: {url}",
|
||||
"personal_survey_link": "Personal Survey Link",
|
||||
@@ -659,13 +688,24 @@
|
||||
"search_contact": "Search contact",
|
||||
"select_a_survey": "Select a survey",
|
||||
"select_attribute": "Select Attribute",
|
||||
"select_attribute_key": "Select attribute key",
|
||||
"survey_viewed": "Survey viewed",
|
||||
"survey_viewed_at": "Viewed At",
|
||||
"system_attributes": "System Attributes",
|
||||
"unlock_contacts_description": "Manage contacts and send out targeted surveys",
|
||||
"unlock_contacts_title": "Unlock contacts with a higher plan",
|
||||
"upload_contacts_error_attribute_type_mismatch": "Attribute \"{key}\" is typed as \"{dataType}\" but CSV contains invalid values: {values}",
|
||||
"upload_contacts_error_duplicate_mappings": "Duplicate mappings found for the following attributes: {attributes}",
|
||||
"upload_contacts_error_file_too_large": "File size exceeds the maximum limit of 800KB",
|
||||
"upload_contacts_error_generic": "An error occurred while uploading the contacts. Please try again later.",
|
||||
"upload_contacts_error_invalid_file_type": "Please upload a CSV file",
|
||||
"upload_contacts_error_no_valid_contacts": "The uploaded CSV file does not contain any valid contacts, please see the sample CSV file for the correct format.",
|
||||
"upload_contacts_modal_attribute_header": "Formbricks Attribute",
|
||||
"upload_contacts_modal_attributes_description": "Map the columns in your CSV to the attributes in Formbricks.",
|
||||
"upload_contacts_modal_attributes_new": "New attribute",
|
||||
"upload_contacts_modal_attributes_search_or_add": "Search or add attribute",
|
||||
"upload_contacts_modal_attributes_should_be_mapped_to": "should be mapped to",
|
||||
"upload_contacts_modal_attributes_title": "Attributes",
|
||||
"upload_contacts_modal_csv_column_header": "CSV Column",
|
||||
"upload_contacts_modal_description": "Upload a CSV to quickly import contacts with attributes",
|
||||
"upload_contacts_modal_download_example_csv": "Download example CSV",
|
||||
"upload_contacts_modal_duplicates_description": "How should we handle if a contact already exists in your contacts?",
|
||||
@@ -722,7 +762,12 @@
|
||||
"link_google_sheet": "Link Google Sheet",
|
||||
"link_new_sheet": "Link new Sheet",
|
||||
"no_integrations_yet": "Your google sheet integrations will appear here as soon as you add them. ⏲️",
|
||||
"spreadsheet_url": "Spreadsheet URL"
|
||||
"reconnect_button": "Reconnect",
|
||||
"reconnect_button_description": "Your Google Sheets connection has expired. Please reconnect to continue syncing responses. Your existing spreadsheet links and data will be preserved.",
|
||||
"reconnect_button_tooltip": "Reconnect the integration to refresh your access. Your existing spreadsheet links and data will be preserved.",
|
||||
"spreadsheet_permission_error": "You don't have permission to access this spreadsheet. Please ensure the spreadsheet is shared with your Google account and you have write access to the spreadsheet.",
|
||||
"spreadsheet_url": "Spreadsheet URL",
|
||||
"token_expired_error": "Google Sheets refresh token has expired or been revoked. Please reconnect the integration."
|
||||
},
|
||||
"include_created_at": "Include Created At",
|
||||
"include_hidden_fields": "Include Hidden Fields",
|
||||
@@ -846,6 +891,40 @@
|
||||
"no_attributes_yet": "No attributes yet!",
|
||||
"no_filters_yet": "There are no filters yet!",
|
||||
"no_segments_yet": "You currently have no saved segments.",
|
||||
"operator_contains": "contains",
|
||||
"operator_does_not_contain": "does not contain",
|
||||
"operator_ends_with": "ends with",
|
||||
"operator_is_after": "is after",
|
||||
"operator_is_before": "is before",
|
||||
"operator_is_between": "is between",
|
||||
"operator_is_newer_than": "is newer than",
|
||||
"operator_is_not_set": "is not set",
|
||||
"operator_is_older_than": "is older than",
|
||||
"operator_is_same_day": "is same day",
|
||||
"operator_is_set": "is set",
|
||||
"operator_starts_with": "starts with",
|
||||
"operator_title_contains": "Contains",
|
||||
"operator_title_does_not_contain": "Does not contain",
|
||||
"operator_title_ends_with": "Ends with",
|
||||
"operator_title_equals": "Equals",
|
||||
"operator_title_greater_equal": "Greater than or equal to",
|
||||
"operator_title_greater_than": "Greater than",
|
||||
"operator_title_is_after": "Is after",
|
||||
"operator_title_is_before": "Is before",
|
||||
"operator_title_is_between": "Is between",
|
||||
"operator_title_is_newer_than": "Is newer than",
|
||||
"operator_title_is_not_set": "Is not set",
|
||||
"operator_title_is_older_than": "Is older than",
|
||||
"operator_title_is_same_day": "Is same day",
|
||||
"operator_title_is_set": "Is set",
|
||||
"operator_title_less_equal": "Less than or equal to",
|
||||
"operator_title_less_than": "Less than",
|
||||
"operator_title_not_equals": "Not equals to",
|
||||
"operator_title_starts_with": "Starts with",
|
||||
"operator_title_user_is_in": "User is in",
|
||||
"operator_title_user_is_not_in": "User is not in",
|
||||
"operator_user_is_in": "User is in",
|
||||
"operator_user_is_not_in": "User is not in",
|
||||
"person_and_attributes": "Person & Attributes",
|
||||
"phone": "Phone",
|
||||
"please_remove_the_segment_from_these_surveys_in_order_to_delete_it": "Please remove the segment from these surveys in order to delete it.",
|
||||
@@ -870,6 +949,7 @@
|
||||
"user_targeting_is_currently_only_available_when": "User targeting is currently only available when",
|
||||
"value_cannot_be_empty": "Value cannot be empty.",
|
||||
"value_must_be_a_number": "Value must be a number.",
|
||||
"value_must_be_positive": "Value must be a positive number.",
|
||||
"view_filters": "View filters",
|
||||
"where": "Where",
|
||||
"with_the_formbricks_sdk": "with the Formbricks SDK"
|
||||
@@ -956,13 +1036,13 @@
|
||||
"enterprise_features": "Enterprise Features",
|
||||
"get_an_enterprise_license_to_get_access_to_all_features": "Get an Enterprise license to get access to all features.",
|
||||
"keep_full_control_over_your_data_privacy_and_security": "Keep full control over your data privacy and security.",
|
||||
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
|
||||
"license_status": "License Status",
|
||||
"license_status_active": "Active",
|
||||
"license_status_description": "Status of your enterprise license.",
|
||||
"license_status_expired": "Expired",
|
||||
"license_status_invalid": "Invalid License",
|
||||
"license_status_unreachable": "Unreachable",
|
||||
"license_invalid_description": "The license key in your ENTERPRISE_LICENSE_KEY environment variable is not valid. Please check for typos or request a new key.",
|
||||
"license_unreachable_grace_period": "License server cannot be reached. Your enterprise features remain active during a 3-day grace period ending {gracePeriodEnd}.",
|
||||
"no_call_needed_no_strings_attached_request_a_free_30_day_trial_license_to_test_all_features_by_filling_out_this_form": "No call needed, no strings attached: Request a free 30-day trial license to test all features by filling out this form:",
|
||||
"no_credit_card_no_sales_call_just_test_it": "No credit card. No sales call. Just test it :)",
|
||||
@@ -1002,7 +1082,7 @@
|
||||
"email_customization_preview_email_heading": "Hey {userName}",
|
||||
"email_customization_preview_email_text": "This is an email preview to show you which logo will be rendered in the emails.",
|
||||
"error_deleting_organization_please_try_again": "Error deleting organization. Please try again.",
|
||||
"from_your_organization": "from your organization",
|
||||
"from_your_organization": "{memberName} from your organization",
|
||||
"invitation_sent_once_more": "Invitation sent once more.",
|
||||
"invite_deleted_successfully": "Invite deleted successfully",
|
||||
"invite_expires_on": "Invite expires on {date}",
|
||||
@@ -1167,6 +1247,7 @@
|
||||
"add_fallback_placeholder": "Add a placeholder to show if there is no value to recall.",
|
||||
"add_hidden_field_id": "Add hidden field ID",
|
||||
"add_highlight_border": "Add highlight border",
|
||||
"add_highlight_border_description": "Only applies to in-product surveys.",
|
||||
"add_logic": "Add logic",
|
||||
"add_none_of_the_above": "Add “None of the Above”",
|
||||
"add_option": "Add option",
|
||||
@@ -1365,7 +1446,6 @@
|
||||
"follow_ups_modal_updated_successfull_toast": "Follow-up updated and will be saved once you save the survey.",
|
||||
"follow_ups_new": "New follow-up",
|
||||
"follow_ups_upgrade_button_text": "Upgrade to enable follow-ups",
|
||||
"form_styling": "Form styling",
|
||||
"formbricks_sdk_is_not_connected": "Formbricks SDK is not connected",
|
||||
"four_points": "4 points",
|
||||
"heading": "Heading",
|
||||
@@ -1538,7 +1618,7 @@
|
||||
"response_limits_redirections_and_more": "Response limits, redirections and more.",
|
||||
"response_options": "Response Options",
|
||||
"roundness": "Roundness",
|
||||
"roundness_description": "Controls how rounded the card corners are.",
|
||||
"roundness_description": "Controls how rounded corners are.",
|
||||
"row_used_in_logic_error": "This row is used in logic of question {questionIndex}. Please remove it from logic first.",
|
||||
"rows": "Rows",
|
||||
"save_and_close": "Save & Close",
|
||||
@@ -1584,8 +1664,9 @@
|
||||
"survey_completed_subheading": "This free & open-source survey has been closed",
|
||||
"survey_display_settings": "Survey Display Settings",
|
||||
"survey_placement": "Survey Placement",
|
||||
"survey_styling": "Survey styling",
|
||||
"survey_trigger": "Survey Trigger",
|
||||
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started \uD83D\uDC49",
|
||||
"switch_multi_language_on_to_get_started": "Switch multi-language on to get started 👉",
|
||||
"target_block_not_found": "Target block not found",
|
||||
"targeted": "Targeted",
|
||||
"ten_points": "10 points",
|
||||
@@ -1674,7 +1755,6 @@
|
||||
"welcome_message": "Welcome message",
|
||||
"without_a_filter_all_of_your_users_can_be_surveyed": "Without a filter, all of your users can be surveyed.",
|
||||
"you_have_not_created_a_segment_yet": "You have not created a segment yet",
|
||||
"you_need_to_have_two_or_more_languages_set_up_in_your_workspace_to_work_with_translations": "You need to have two or more languages set up in your workspace to work with translations.",
|
||||
"your_description_here_recall_information_with": "Your description here. Recall information with @",
|
||||
"your_question_here_recall_information_with": "Your question here. Recall information with @",
|
||||
"your_web_app": "Your web app",
|
||||
@@ -1882,6 +1962,7 @@
|
||||
"filtered_responses_excel": "Filtered responses (Excel)",
|
||||
"generating_qr_code": "Generating QR code",
|
||||
"impressions": "Impressions",
|
||||
"impressions_identified_only": "Only showing impressions from identified contacts",
|
||||
"impressions_tooltip": "Number of times the survey has been viewed.",
|
||||
"in_app": {
|
||||
"connection_description": "The survey will be shown to users of your website, that match the criteria listed below",
|
||||
@@ -1924,6 +2005,7 @@
|
||||
"last_quarter": "Last quarter",
|
||||
"last_year": "Last year",
|
||||
"limit": "Limit",
|
||||
"no_identified_impressions": "No impressions from identified contacts",
|
||||
"no_responses_found": "No responses found",
|
||||
"other_values_found": "Other values found",
|
||||
"overall": "Overall",
|
||||
@@ -1946,6 +2028,7 @@
|
||||
"starts": "Starts",
|
||||
"starts_tooltip": "Number of times the survey has been started.",
|
||||
"survey_reset_successfully": "Survey reset successfully. {responseCount} responses and {displayCount} displays were deleted.",
|
||||
"survey_results": "{surveyName} Results",
|
||||
"this_month": "This month",
|
||||
"this_quarter": "This quarter",
|
||||
"this_year": "This year",
|
||||
@@ -2003,7 +2086,7 @@
|
||||
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
|
||||
"how_to_setup": "How to setup",
|
||||
"how_to_setup_description": "Follow these steps to setup the Formbricks widget within your app.",
|
||||
"receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A",
|
||||
"receiving_data": "Receiving data 💃🕺",
|
||||
"recheck": "Re-check",
|
||||
"sdk_connection_details": "SDK Connection Details",
|
||||
"sdk_connection_details_description": "Your unique environment ID and SDK connection URL for integrating Formbricks with your application.",
|
||||
@@ -2079,7 +2162,7 @@
|
||||
"advanced_styling_field_description_size": "Description Font Size",
|
||||
"advanced_styling_field_description_size_description": "Scales the description text.",
|
||||
"advanced_styling_field_description_weight": "Description Font Weight",
|
||||
"advanced_styling_field_description_weight_description": "Makes description text lighter or bolder.",
|
||||
"advanced_styling_field_description_weight_description": "Makes descr. text lighter or bolder.",
|
||||
"advanced_styling_field_font_size": "Font Size",
|
||||
"advanced_styling_field_font_weight": "Font Weight",
|
||||
"advanced_styling_field_headline_color": "Headline Color",
|
||||
@@ -2088,12 +2171,12 @@
|
||||
"advanced_styling_field_headline_size_description": "Scales the headline text.",
|
||||
"advanced_styling_field_headline_weight": "Headline Font Weight",
|
||||
"advanced_styling_field_headline_weight_description": "Makes headline text lighter or bolder.",
|
||||
"advanced_styling_field_height": "Height",
|
||||
"advanced_styling_field_height": "Minimum Height",
|
||||
"advanced_styling_field_indicator_bg": "Indicator Background",
|
||||
"advanced_styling_field_indicator_bg_description": "Colors the filled portion of the bar.",
|
||||
"advanced_styling_field_input_border_radius_description": "Rounds the input corners.",
|
||||
"advanced_styling_field_input_font_size_description": "Scales the typed text in inputs.",
|
||||
"advanced_styling_field_input_height_description": "Controls the input field height.",
|
||||
"advanced_styling_field_input_height_description": "Controls the min. height of the input.",
|
||||
"advanced_styling_field_input_padding_x_description": "Adds space on the left and right.",
|
||||
"advanced_styling_field_input_padding_y_description": "Adds space on the top and bottom.",
|
||||
"advanced_styling_field_input_placeholder_opacity_description": "Fades the placeholder hint text.",
|
||||
@@ -2102,6 +2185,8 @@
|
||||
"advanced_styling_field_input_text_description": "Colors the typed text in inputs.",
|
||||
"advanced_styling_field_option_bg": "Background",
|
||||
"advanced_styling_field_option_bg_description": "Fills the option items.",
|
||||
"advanced_styling_field_option_border": "Border Color",
|
||||
"advanced_styling_field_option_border_description": "Outlines radio and checkbox options.",
|
||||
"advanced_styling_field_option_border_radius_description": "Rounds the option corners.",
|
||||
"advanced_styling_field_option_font_size_description": "Scales the option label text.",
|
||||
"advanced_styling_field_option_label": "Label Color",
|
||||
@@ -2156,6 +2241,7 @@
|
||||
"show_powered_by_formbricks": "Show “Powered by Formbricks” Signature",
|
||||
"styling_updated_successfully": "Styling updated successfully",
|
||||
"suggest_colors": "Suggest colors",
|
||||
"suggested_colors_applied_please_save": "Suggested colors generated successfully. Press \"Save\" to persist the changes.",
|
||||
"theme": "Theme",
|
||||
"theme_settings_description": "Create a style theme for all surveys. You can enable custom styling for each survey."
|
||||
},
|
||||
@@ -2261,7 +2347,7 @@
|
||||
"setup": {
|
||||
"intro": {
|
||||
"get_started": "Get started",
|
||||
"made_with_love_in_kiel": "Made with \uD83E\uDD0D in Germany",
|
||||
"made_with_love_in_kiel": "Made with 🤍 in Germany",
|
||||
"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 are committed to the highest degree of data privacy. Self-host to keep <b>full control over your data</b>.",
|
||||
@@ -2411,7 +2497,7 @@
|
||||
"churn_survey_question_3_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We would love to keep you as a customer. Happy to offer a 30% discount for the next year.</span></p>",
|
||||
"churn_survey_question_4_headline": "What features are you missing?",
|
||||
"churn_survey_question_5_button_label": "Send email to CEO",
|
||||
"churn_survey_question_5_headline": "So sorry to hear \uD83D\uDE14 Talk to our CEO directly!",
|
||||
"churn_survey_question_5_headline": "So sorry to hear 😔 Talk to our CEO directly!",
|
||||
"churn_survey_question_5_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>We aim to provide the best possible customer service. Please email our CEO and she will personally handle your issue.</span></p>",
|
||||
"collect_feedback_description": "Gather comprehensive feedback on your product or service.",
|
||||
"collect_feedback_name": "Collect Feedback",
|
||||
@@ -2539,8 +2625,8 @@
|
||||
"default_welcome_card_html": "Thanks for providing your feedback - let’s go!",
|
||||
"docs_feedback_description": "Measure how clear each page of your developer documentation is.",
|
||||
"docs_feedback_name": "Docs Feedback",
|
||||
"docs_feedback_question_1_choice_1": "Yes \uD83D\uDC4D",
|
||||
"docs_feedback_question_1_choice_2": "No \uD83D\uDC4E",
|
||||
"docs_feedback_question_1_choice_1": "Yes 👍",
|
||||
"docs_feedback_question_1_choice_2": "No 👎",
|
||||
"docs_feedback_question_1_headline": "Was this page helpful?",
|
||||
"docs_feedback_question_2_headline": "Please elaborate:",
|
||||
"docs_feedback_question_3_headline": "Page URL",
|
||||
@@ -2670,7 +2756,7 @@
|
||||
"file_upload": "File Upload",
|
||||
"file_upload_description": "Enable respondents to upload documents, images, or other files",
|
||||
"finish": "Finish",
|
||||
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey \uD83D\uDC4B</span><br><br><span style=\"white-space: pre-wrap;\">Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span style=\"white-space: pre-wrap;\">Have a great day!</span></p>",
|
||||
"follow_ups_modal_action_body": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span style=\"white-space: pre-wrap;\">Hey 👋</span><br><br><span style=\"white-space: pre-wrap;\">Thanks for taking the time to respond, we will be in touch shortly.</span><br><br><span style=\"white-space: pre-wrap;\">Have a great day!</span></p>",
|
||||
"free_text": "Free text",
|
||||
"free_text_description": "Collect open-ended feedback",
|
||||
"free_text_placeholder": "Type your answer here…",
|
||||
@@ -2708,7 +2794,7 @@
|
||||
"identify_sign_up_barriers_question_8_placeholder": "Type your answer here…",
|
||||
"identify_sign_up_barriers_question_9_button_label": "Sign Up",
|
||||
"identify_sign_up_barriers_question_9_headline": "Thanks! Here is your code: SIGNUPNOW10",
|
||||
"identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Thanks a lot for taking the time to share feedback \uD83D\uDE4F</span></p>",
|
||||
"identify_sign_up_barriers_question_9_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>Thanks a lot for taking the time to share feedback 🙏</span></p>",
|
||||
"identify_upsell_opportunities_description": "Find out how much time your product saves your user. Use it to upsell.",
|
||||
"identify_upsell_opportunities_name": "Identify Upsell Opportunities",
|
||||
"identify_upsell_opportunities_question_1_choice_1": "Less than 1 hour",
|
||||
@@ -2920,6 +3006,9 @@
|
||||
"preview_survey_question_2_choice_2_label": "No, thank you!",
|
||||
"preview_survey_question_2_headline": "Want to stay in the loop?",
|
||||
"preview_survey_question_2_subheader": "This is an example description.",
|
||||
"preview_survey_question_open_text_headline": "Anything else you'd like to share?",
|
||||
"preview_survey_question_open_text_placeholder": "Type your answer here…",
|
||||
"preview_survey_question_open_text_subheader": "Your feedback helps us improve.",
|
||||
"preview_survey_welcome_card_headline": "Welcome!",
|
||||
"prioritize_features_description": "Identify features your users need most and least.",
|
||||
"prioritize_features_name": "Prioritize Features",
|
||||
@@ -3037,7 +3126,7 @@
|
||||
"review_prompt_question_1_lower_label": "Not good",
|
||||
"review_prompt_question_1_upper_label": "Very satisfied",
|
||||
"review_prompt_question_2_button_label": "Write review",
|
||||
"review_prompt_question_2_headline": "Happy to hear \uD83D\uDE4F Please write a review for us!",
|
||||
"review_prompt_question_2_headline": "Happy to hear 🙏 Please write a review for us!",
|
||||
"review_prompt_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>This helps us a lot.</span></p>",
|
||||
"review_prompt_question_3_button_label": "Send",
|
||||
"review_prompt_question_3_headline": "Sorry to hear! What is ONE thing we can do better?",
|
||||
@@ -3080,7 +3169,7 @@
|
||||
"smileys_survey_question_1_lower_label": "Not good",
|
||||
"smileys_survey_question_1_upper_label": "Very satisfied",
|
||||
"smileys_survey_question_2_button_label": "Write review",
|
||||
"smileys_survey_question_2_headline": "Happy to hear \uD83D\uDE4F Please write a review for us!",
|
||||
"smileys_survey_question_2_headline": "Happy to hear 🙏 Please write a review for us!",
|
||||
"smileys_survey_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>This helps us a lot.</span></p>",
|
||||
"smileys_survey_question_3_button_label": "Send",
|
||||
"smileys_survey_question_3_headline": "Sorry to hear! What is ONE thing we can do better?",
|
||||
@@ -3091,7 +3180,7 @@
|
||||
"star_rating_survey_question_1_lower_label": "Extremely dissatisfied",
|
||||
"star_rating_survey_question_1_upper_label": "Extremely satisfied",
|
||||
"star_rating_survey_question_2_button_label": "Write review",
|
||||
"star_rating_survey_question_2_headline": "Happy to hear \uD83D\uDE4F Please write a review for us!",
|
||||
"star_rating_survey_question_2_headline": "Happy to hear 🙏 Please write a review for us!",
|
||||
"star_rating_survey_question_2_html": "<p class=\"fb-editor-paragraph\" dir=\"ltr\"><span>This helps us a lot.</span></p>",
|
||||
"star_rating_survey_question_3_button_label": "Send",
|
||||
"star_rating_survey_question_3_headline": "Sorry to hear! What is ONE thing we can do better?",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user