feat: UI improvements to survey editor and summary cards (#6857)

This commit is contained in:
Johannes
2025-11-25 01:49:59 -08:00
committed by GitHub
parent c03c7ec1ed
commit f07092595f
37 changed files with 652 additions and 403 deletions

View File

@@ -8,6 +8,7 @@ import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface AddressSummaryProps {
@@ -29,42 +30,48 @@ export const AddressSummary = ({ questionSummary, environmentId, survey, locale
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.map((response) => {
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
{questionSummary.samples.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
) : (
questionSummary.samples.map((response) => {
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<ArrayResponse value={response.value} />
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<ArrayResponse value={response.value} />
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
</div>
);
})}
);
})
)}
</div>
</div>
</div>

View File

@@ -8,6 +8,7 @@ import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { ArrayResponse } from "@/modules/ui/components/array-response";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface ContactInfoSummaryProps {
@@ -34,42 +35,48 @@ export const ContactInfoSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.map((response) => {
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
{questionSummary.samples.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
) : (
questionSummary.samples.map((response) => {
return (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<ArrayResponse value={response.value} />
</div>
)}
</div>
<div className="ph-no-capture col-span-2 pl-6 font-semibold">
<ArrayResponse value={response.value} />
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
</div>
);
})}
);
})
)}
</div>
</div>
</div>

View File

@@ -10,6 +10,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { formatDateWithOrdinal } from "@/lib/utils/datetime";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface DateQuestionSummary {
@@ -55,41 +56,47 @@ export const DateQuestionSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
{questionSummary.samples.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
))}
) : (
questionSummary.samples.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{renderResponseValue(response.value)}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))
)}
</div>
{visibleResponses < questionSummary.samples.length && (
{questionSummary.samples.length > 0 && visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}

View File

@@ -11,6 +11,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { getOriginalFileNameFromUrl } from "@/modules/storage/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
interface FileUploadSummaryProps {
@@ -45,71 +46,77 @@ export const FileUploadSummary = ({
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
<div className="max-h-[62vh] w-full overflow-y-auto">
{questionSummary.files.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="col-span-2 grid">
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
{t("common.skipped")}
</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
{questionSummary.files.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
))}
) : (
questionSummary.files.slice(0, visibleResponses).map((response) => (
<div
key={response.id}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 last:border-transparent md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="col-span-2 grid">
{Array.isArray(response.value) &&
(response.value.length > 0 ? (
response.value.map((fileUrl) => {
const fileName = getOriginalFileNameFromUrl(fileUrl);
return (
<div className="relative m-2 rounded-lg bg-slate-200" key={fileUrl}>
<a href={fileUrl} key={fileUrl} target="_blank" rel="noopener noreferrer">
<div className="absolute right-0 top-0 m-2">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-50 hover:bg-white">
<DownloadIcon className="h-6 text-slate-500" />
</div>
</div>
</a>
<div className="flex flex-col items-center justify-center p-2">
<FileIcon className="h-6 text-slate-500" />
<p className="mt-2 text-sm text-slate-500 dark:text-slate-400">{fileName}</p>
</div>
</div>
);
})
) : (
<div className="flex w-full flex-col items-center justify-center p-2">
<p className="mt-2 text-sm font-semibold text-slate-500 dark:text-slate-400">
{t("common.skipped")}
</p>
</div>
))}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))
)}
</div>
{visibleResponses < questionSummary.files.length && (
{questionSummary.files.length > 0 && visibleResponses < questionSummary.files.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}

View File

@@ -10,6 +10,7 @@ import { timeSince } from "@/lib/time";
import { getContactIdentifier } from "@/lib/utils/contact";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
interface HiddenFieldsSummaryProps {
environment: TEnvironment;
@@ -51,40 +52,46 @@ export const HiddenFieldsSummary = ({ environment, questionSummary, locale }: Hi
<div className="col-span-2 pl-4 md:pl-6">{t("common.response")}</div>
<div className="px-4 md:px-6">{t("common.time")}</div>
</div>
{questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
<div
key={`${response.value}-${idx}`}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
{questionSummary.samples.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
))}
{visibleResponses < questionSummary.samples.length && (
) : (
questionSummary.samples.slice(0, visibleResponses).map((response, idx) => (
<div
key={`${response.value}-${idx}`}
className="grid grid-cols-4 items-center border-b border-slate-100 py-2 text-sm text-slate-800 md:text-base">
<div className="pl-4 md:pl-6">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environment.id}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-all text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</div>
<div className="ph-no-capture col-span-2 whitespace-pre-wrap pl-6 font-semibold">
{response.value}
</div>
<div className="px-4 text-slate-500 md:px-6">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</div>
</div>
))
)}
{questionSummary.samples.length > 0 && visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}

View File

@@ -85,96 +85,98 @@ export const MultipleChoiceSummary = ({
) : undefined
}
/>
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{results.map((result) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
<button
type="button"
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</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")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
</div>
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
<div className="px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
<div className="space-y-5">
{results.map((result) => {
const choiceId = getChoiceIdByValue(result.value, questionSummary.question);
return (
<Fragment key={result.value}>
<button
type="button"
className="group w-full cursor-pointer"
onClick={() =>
setFilter(
questionSummary.question.id,
questionSummary.question.headline,
questionSummary.question.type,
questionSummary.type === "multipleChoiceSingle" || otherValue === result.value
? t("environments.surveys.summary.includes_either")
: t("environments.surveys.summary.includes_all"),
[result.value]
)
}>
<div className="text flex flex-col justify-between px-2 pb-2 sm:flex-row">
<div className="mr-8 flex w-full justify-between space-x-2 sm:justify-normal">
<p className="font-semibold text-slate-700 underline-offset-4 group-hover:underline">
{result.value}
</p>
{choiceId && <IdBadge id={choiceId} />}
</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")}
</p>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(result.percentage, 2)}%
</p>
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<div className="group-hover:opacity-80">
<ProgressBar barColor="bg-brand-dark" progress={result.percentage / 100} />
</div>
</button>
{result.others && result.others.length > 0 && (
<div className="mt-4 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1 pl-6">
{t("environments.surveys.summary.other_values_found")}
</div>
<div className="col-span-1 pl-6">{surveyType === "app" && t("common.user")}</div>
</div>
{result.others
.filter((otherValue) => otherValue.value !== "")
.slice(0, visibleOtherResponses)
.map((otherValue, idx) => (
<div key={`${idx}-${otherValue}`} dir="auto">
{surveyType === "link" && (
<div className="ph-no-capture col-span-1 m-2 flex h-10 items-center rounded-lg pl-4 text-sm font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
)}
{surveyType === "app" && otherValue.contact && (
<Link
href={
otherValue.contact.id
? `/environments/${environmentId}/contacts/${otherValue.contact.id}`
: { pathname: null }
}
className="m-2 grid h-16 grid-cols-2 items-center rounded-lg text-sm hover:bg-slate-100">
<div className="ph-no-capture col-span-1 pl-4 font-medium text-slate-900">
<span>{otherValue.value}</span>
</div>
<div className="ph-no-capture col-span-1 flex items-center space-x-4 pl-6 font-medium text-slate-900">
{otherValue.contact.id && <PersonAvatar personId={otherValue.contact.id} />}
<span>
{getContactIdentifier(otherValue.contact, otherValue.contactAttributes)}
</span>
</div>
</Link>
)}
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
))}
{visibleOtherResponses < result.others.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</Fragment>
);
})}
)}
</div>
)}
</Fragment>
);
})}
</div>
</div>
</div>
);

View File

@@ -106,36 +106,38 @@ export const NPSSummary = ({ questionSummary, survey, setFilter }: NPSSummaryPro
</div>
<TabsContent value="aggregated" className="mt-4">
<div className="space-y-5 px-4 pb-6 pt-4 text-sm md:px-6 md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
<div className="px-4 pb-6 pt-4 md:px-6">
<div className="space-y-5 text-sm md:text-base">
{["promoters", "passives", "detractors", "dismissed"].map((group) => (
<button
className="w-full cursor-pointer hover:opacity-80"
key={group}
onClick={() => applyFilter(group)}>
<div
className={`mb-2 flex justify-between ${group === "dismissed" ? "mb-2 border-t bg-white pt-4 text-sm md:text-base" : ""}`}>
<div className="mr-8 flex space-x-1">
<p
className={`font-semibold capitalize text-slate-700 ${group === "dismissed" ? "" : "text-slate-700"}`}>
{group}
</p>
<div>
<p className="rounded-lg bg-slate-100 px-2 text-slate-700">
{convertFloatToNDecimal(questionSummary[group]?.percentage, 2)}%
</p>
</div>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<p className="flex w-32 items-end justify-end text-slate-600">
{questionSummary[group]?.count}{" "}
{questionSummary[group]?.count === 1 ? t("common.response") : t("common.responses")}
</p>
</div>
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
<ProgressBar
barColor={group === "dismissed" ? "bg-slate-600" : "bg-brand-dark"}
progress={questionSummary[group]?.percentage / 100}
/>
</button>
))}
</div>
</div>
</TabsContent>

View File

@@ -10,6 +10,7 @@ import { getContactIdentifier } from "@/lib/utils/contact";
import { renderHyperlinkedContent } from "@/modules/analysis/utils";
import { PersonAvatar } from "@/modules/ui/components/avatars";
import { Button } from "@/modules/ui/components/button";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/modules/ui/components/table";
import { QuestionSummaryHeader } from "./QuestionSummaryHeader";
@@ -35,59 +36,65 @@ export const OpenTextSummary = ({ questionSummary, environmentId, survey, locale
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<QuestionSummaryHeader questionSummary={questionSummary} survey={survey} />
<div className="border-t border-slate-200"></div>
<div className="max-h-[40vh] overflow-y-auto">
<Table>
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead>{t("common.user")}</TableHead>
<TableHead>{t("common.response")}</TableHead>
<TableHead>{t("common.time")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell>
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</TableCell>
<TableCell className="font-medium">
{typeof response.value === "string"
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell width={120}>
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
{questionSummary.samples.length === 0 ? (
<div className="p-8">
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
</div>
) : (
<div className="max-h-[40vh] overflow-y-auto">
<Table>
<TableHeader className="bg-slate-100">
<TableRow>
<TableHead className="w-1/4">{t("common.user")}</TableHead>
<TableHead className="w-2/4">{t("common.response")}</TableHead>
<TableHead className="w-1/4">{t("common.time")}</TableHead>
</TableRow>
))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
</TableHeader>
<TableBody>
{questionSummary.samples.slice(0, visibleResponses).map((response) => (
<TableRow key={response.id}>
<TableCell className="w-1/4">
{response.contact ? (
<Link
className="ph-no-capture group flex items-center"
href={`/environments/${environmentId}/contacts/${response.contact.id}`}>
<div className="hidden md:flex">
<PersonAvatar personId={response.contact.id} />
</div>
<p className="ph-no-capture break-all text-slate-600 group-hover:underline md:ml-2">
{getContactIdentifier(response.contact, response.contactAttributes)}
</p>
</Link>
) : (
<div className="group flex items-center">
<div className="hidden md:flex">
<PersonAvatar personId="anonymous" />
</div>
<p className="break-normal text-slate-600 md:ml-2">{t("common.anonymous")}</p>
</div>
)}
</TableCell>
<TableCell className="w-2/4 font-medium">
{typeof response.value === "string"
? renderHyperlinkedContent(response.value)
: response.value}
</TableCell>
<TableCell className="w-1/4">
{timeSince(new Date(response.updatedAt).toISOString(), locale)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{visibleResponses < questionSummary.samples.length && (
<div className="flex justify-center py-4">
<Button onClick={handleLoadMore} variant="secondary" size="sm">
{t("common.load_more")}
</Button>
</div>
)}
</div>
)}
</div>
);
};

View File

@@ -11,6 +11,7 @@ import {
TSurveyQuestionTypeEnum,
} from "@formbricks/types/surveys/types";
import { convertFloatToNDecimal } from "@/app/(app)/environments/[environmentId]/surveys/[surveyId]/(analysis)/summary/lib/utils";
import { EmptyState } from "@/modules/ui/components/empty-state";
import { ProgressBar } from "@/modules/ui/components/progress-bar";
import { RatingResponse } from "@/modules/ui/components/rating-response";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/modules/ui/components/tabs";
@@ -84,11 +85,7 @@ export const RatingSummary = ({ questionSummary, survey, setFilter }: RatingSumm
<div className="px-4 pb-6 pt-4 md:px-6">
{questionSummary.responseCount === 0 ? (
<>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-sm text-slate-500">
{t("environments.surveys.summary.no_responses_found")}
</p>
</div>
<EmptyState text={t("environments.surveys.summary.no_responses_found")} variant="simple" />
<RatingScaleLegend
scale={questionSummary.question.scale}
range={questionSummary.question.range}

View File

@@ -8,6 +8,7 @@ import { cn } from "@/modules/ui/lib/utils";
interface SummaryMetadataProps {
surveySummary: TSurveySummary["meta"];
quotasCount: number;
isLoading: boolean;
tab: "dropOffs" | "quotas" | undefined;
setTab: React.Dispatch<React.SetStateAction<"dropOffs" | "quotas" | undefined>>;
@@ -31,6 +32,7 @@ const formatTime = (ttc) => {
export const SummaryMetadata = ({
surveySummary,
quotasCount,
isLoading,
tab,
setTab,
@@ -61,7 +63,7 @@ export const SummaryMetadata = ({
<div
className={cn(
`grid gap-4 sm:grid-cols-2 md:grid-cols-3 md:gap-x-2 lg:grid-cols-3 2xl:grid-cols-5`,
isQuotasAllowed && "2xl:grid-cols-6"
isQuotasAllowed && quotasCount > 0 && "2xl:grid-cols-6"
)}>
<StatCard
label={t("environments.surveys.summary.impressions")}
@@ -105,7 +107,7 @@ export const SummaryMetadata = ({
isLoading={isLoading}
/>
{isQuotasAllowed && (
{isQuotasAllowed && quotasCount > 0 && (
<InteractiveCard
key="quotas"
tab="quotas"

View File

@@ -115,6 +115,7 @@ export const SummaryPage = ({
<>
<SummaryMetadata
surveySummary={surveySummary.meta}
quotasCount={surveySummary.quotas?.length ?? 0}
isLoading={isLoading}
tab={tab}
setTab={setTab}

View File

@@ -209,7 +209,7 @@ export const QuestionsComboBox = ({ options, selected, onChangeValue }: Question
{open && (
<div className="animate-in absolute top-full z-10 mt-1 w-full overflow-auto rounded-md shadow-md outline-none">
<CommandList>
<CommandList className="max-h-[600px]">
<CommandEmpty>{t("common.no_result_found")}</CommandEmpty>
{options?.map((data) => (
<Fragment key={data.header}>

View File

@@ -268,6 +268,64 @@ describe("surveys", () => {
expect(sourceFilterOption).toBeDefined();
expect(sourceFilterOption?.filterOptions).toEqual(["Equals", "Not equals"]);
});
test("should include quota options in filter options when quotas are provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const quotas = [{ id: "quota1" }];
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
const quotaFilterOption = result.questionFilterOptions.find((o) => o.id === "quota1");
expect(quotaFilterOption).toBeDefined();
expect(quotaFilterOption?.type).toBe("Quotas");
expect(quotaFilterOption?.filterOptions).toEqual(["Status"]);
expect(quotaFilterOption?.filterComboBoxOptions).toEqual([
"Screened in",
"Screened out (overquota)",
"Not in quota",
]);
});
test("should include multiple quota options when multiple quotas are provided", () => {
const survey = {
id: "survey1",
name: "Test Survey",
questions: [],
createdAt: new Date(),
updatedAt: new Date(),
environmentId: "env1",
status: "draft",
} as unknown as TSurvey;
const quotas = [{ id: "quota1" }, { id: "quota2" }];
const result = generateQuestionAndFilterOptions(survey, undefined, {}, {}, {}, quotas as any);
const quota1 = result.questionFilterOptions.find((o) => o.id === "quota1");
const quota2 = result.questionFilterOptions.find((o) => o.id === "quota2");
expect(quota1).toBeDefined();
expect(quota2).toBeDefined();
expect(quota1?.filterComboBoxOptions).toEqual([
"Screened in",
"Screened out (overquota)",
"Not in quota",
]);
expect(quota2?.filterComboBoxOptions).toEqual([
"Screened in",
"Screened out (overquota)",
"Not in quota",
]);
});
});
describe("getFormattedFilters", () => {
@@ -867,6 +925,75 @@ describe("surveys", () => {
expect(result.meta?.url).toEqual({ op: "contains", value: "formbricks.com" });
expect(result.meta?.source).toEqual({ op: "equals", value: "newsletter" });
});
test("should filter by quota with screened in status", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened in" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
});
test("should filter by quota with screened out status", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened out (overquota)" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedOut" });
});
test("should filter by quota with not in quota status", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Not in quota" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedOutNotInQuota" });
});
test("should filter by multiple quotas with different statuses", () => {
const selectedFilter: SelectedFilterValue = {
responseStatus: "all",
filter: [
{
questionType: { type: "Quotas", label: "Quota 1", id: "quota1" },
filterType: { filterComboBoxValue: "Screened in" },
},
{
questionType: { type: "Quotas", label: "Quota 2", id: "quota2" },
filterType: { filterComboBoxValue: "Not in quota" },
},
],
} as any;
const result = getFormattedFilters(survey, selectedFilter, {} as any);
expect(result.quotas?.quota1).toEqual({ op: "screenedIn" });
expect(result.quotas?.quota2).toEqual({ op: "screenedOutNotInQuota" });
});
});
describe("getTodayDate", () => {

View File

@@ -236,7 +236,7 @@ export const generateQuestionAndFilterOptions = (
questionFilterOptions.push({
type: "Quotas",
filterOptions: ["Status"],
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Screened out (not in quota)"],
filterComboBoxOptions: ["Screened in", "Screened out (overquota)", "Not in quota"],
id: quota.id,
});
});
@@ -549,7 +549,7 @@ export const getFormattedFilters = (
const statusMap: Record<string, "screenedIn" | "screenedOut" | "screenedOutNotInQuota"> = {
"Screened in": "screenedIn",
"Screened out (overquota)": "screenedOut",
"Screened out (not in quota)": "screenedOutNotInQuota",
"Not in quota": "screenedOutNotInQuota",
};
const op = statusMap[String(filterType.filterComboBoxValue)];
if (op) filters.quotas[quotaId] = { op };

View File

@@ -2073,7 +2073,7 @@ const careerDevelopmentSurvey = (t: TFunction): TTemplate => {
return buildSurvey(
{
name: t("templates.career_development_survey_name"),
role: "productManager",
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.career_development_survey_description"),
@@ -2160,7 +2160,7 @@ const professionalDevelopmentSurvey = (t: TFunction): TTemplate => {
return buildSurvey(
{
name: t("templates.professional_development_survey_name"),
role: "productManager",
role: "peopleManager",
industries: ["saas", "eCommerce", "other"],
channels: ["link"],
description: t("templates.professional_development_survey_description"),

View File

@@ -304,7 +304,7 @@ checksums:
common/project_not_found: be3b516c02b05553acb4ae338511f645
common/project_permission_not_found: ace6b03f06bd14e884e4295c5022d61b
common/projects: fe8af5cfb3c95cb35534872a325b225e
common/question: 0576462ce60d4263d7c482463fcc9547
common/question: 2a47e06b62410b16003c4979dee0099f
common/question_id: d0c3672976c281411bdccf749faf5ffd
common/questions: 38d08215fd7a8026077c7b64eea6bb59
common/quota: edd33b180b463ee7a70a64a5c4ad7f02
@@ -748,8 +748,11 @@ checksums:
environments/project/app-connection/how_to_setup_description: 2ae5cd9456a8acd3986e3d3678e70ed2
environments/project/app-connection/receiving_data: 9f2a48c0b0278861add70b526061264c
environments/project/app-connection/recheck: f95f2bbe6990a123d60255c87bdd59f7
environments/project/app-connection/sdk_connection_details: 89f2c169fd1604c1df5a834517f1eae1
environments/project/app-connection/sdk_connection_details_description: d9b5d06776a139aef6fc8ed53d71bf0a
environments/project/app-connection/setup_alert_description: 6d676044d01dc2147731ffab7df6c259
environments/project/app-connection/setup_alert_title: 9561cca2b391e0df81e8a982921ff2bb
environments/project/app-connection/webapp_url: d64d8cc3c4c4ecce780d94755f7e4de9
environments/project/general/cannot_delete_only_project: 24751701a42d8b4d2ba6112a5f642bad
environments/project/general/delete_project: e4a2a227105c4ec71e561ab1f140eb26
environments/project/general/delete_project_confirmation: 54a4ee78867537e0244c7170453cdb3f

View File

@@ -801,8 +801,11 @@
"how_to_setup_description": "Befolge diese Schritte, um das Formbricks Widget in deiner App einzurichten.",
"receiving_data": "Daten werden empfangen 💃🕺",
"recheck": "Erneut prüfen",
"sdk_connection_details": "SDK-Verbindungsdetails",
"sdk_connection_details_description": "Deine eindeutige Umgebungs-ID und SDK-Verbindungs-URL zur Integration von Formbricks mit deiner Anwendung.",
"setup_alert_description": "Befolge dieses Schritt-für-Schritt-Tutorial, um deine App oder Website in weniger als 5 Minuten zu verbinden.",
"setup_alert_title": "Wie man verbindet"
"setup_alert_title": "Wie man verbindet",
"webapp_url": "SDK-Verbindungs-URL"
},
"general": {
"cannot_delete_only_project": "Dies ist dein einziges Projekt, es kann nicht gelöscht werden. Erstelle zuerst ein neues Projekt.",

View File

@@ -331,7 +331,7 @@
"project_not_found": "Project not found",
"project_permission_not_found": "Project permission not found",
"projects": "Projects",
"question": "Question",
"question": "question",
"question_id": "Question ID",
"questions": "Questions",
"quota": "Quota",
@@ -794,6 +794,8 @@
"cache_update_delay_title": "Changes will be reflected after ~1 minute due to caching",
"environment_id": "Your Environment ID",
"environment_id_description": "This id uniquely identifies this Formbricks environment.",
"sdk_connection_details": "SDK Connection Details",
"sdk_connection_details_description": "Your unique environment ID and SDK connection URL for integrating Formbricks with your application.",
"formbricks_sdk_connected": "Formbricks SDK is connected",
"formbricks_sdk_not_connected": "Formbricks SDK is not yet connected.",
"formbricks_sdk_not_connected_description": "Add the Formbricks SDK to your website or app to connect it with Formbricks",
@@ -802,7 +804,8 @@
"receiving_data": "Receiving data \uD83D\uDC83\uD83D\uDD7A",
"recheck": "Re-check",
"setup_alert_description": "Follow this step-by-step tutorial to connect your app or website in under 5 minutes.",
"setup_alert_title": "How to connect"
"setup_alert_title": "How to connect",
"webapp_url": "SDK Connection URL"
},
"general": {
"cannot_delete_only_project": "This is your only project, it cannot be deleted. Create a new project first.",

View File

@@ -331,7 +331,7 @@
"project_not_found": "Proyecto no encontrado",
"project_permission_not_found": "Permiso de proyecto no encontrado",
"projects": "Proyectos",
"question": "Pregunta",
"question": "pregunta",
"question_id": "ID de pregunta",
"questions": "Preguntas",
"quota": "Cuota",
@@ -801,8 +801,11 @@
"how_to_setup_description": "Sigue estos pasos para configurar el widget de Formbricks en tu aplicación.",
"receiving_data": "Recibiendo datos 💃🕺",
"recheck": "Volver a comprobar",
"sdk_connection_details": "Detalles de conexión del SDK",
"sdk_connection_details_description": "Tu ID de entorno único y URL de conexión del SDK para integrar Formbricks con tu aplicación.",
"setup_alert_description": "Sigue este tutorial paso a paso para conectar tu aplicación o sitio web en menos de 5 minutos.",
"setup_alert_title": "Cómo conectar"
"setup_alert_title": "Cómo conectar",
"webapp_url": "URL de conexión del SDK"
},
"general": {
"cannot_delete_only_project": "Este es tu único proyecto, no se puede eliminar. Crea un proyecto nuevo primero.",

View File

@@ -331,7 +331,7 @@
"project_not_found": "Projet non trouvé",
"project_permission_not_found": "Autorisation de projet non trouvée",
"projects": "Projets",
"question": "Question",
"question": "question",
"question_id": "ID de la question",
"questions": "Questions",
"quota": "Quota",
@@ -801,8 +801,11 @@
"how_to_setup_description": "Suivez ces étapes pour configurer le widget Formbricks dans votre application.",
"receiving_data": "Réception des données 💃🕺",
"recheck": "Réessayer",
"sdk_connection_details": "Détails de connexion SDK",
"sdk_connection_details_description": "Votre ID d'environnement unique et votre URL de connexion SDK pour intégrer Formbricks à votre application.",
"setup_alert_description": "Suivez les indications de ce tutoriel pour connecter votre application ou votre site Web en moins de cinq minutes.",
"setup_alert_title": "Connexion"
"setup_alert_title": "Connexion",
"webapp_url": "URL de connexion SDK"
},
"general": {
"cannot_delete_only_project": "Comme il s'agit de votre seul projet, il ne peut pas être supprimé. Créez d'abord un nouveau projet.",

View File

@@ -801,8 +801,11 @@
"how_to_setup_description": "アプリ内でFormbricksウィジェットを設定する手順に従ってください。",
"receiving_data": "データ受信中 💃🕺",
"recheck": "再チェック",
"sdk_connection_details": "SDK接続詳細",
"sdk_connection_details_description": "FormbricksをアプリケーションとAPI統合するためのEnvironmentIdとSDK接続URL。",
"setup_alert_description": "5 分以内でアプリまたはウェブサイト を 接続する手順をステップバイステップ の チュートリアルに従ってください。",
"setup_alert_title": "接続方法"
"setup_alert_title": "接続方法",
"webapp_url": "SDK接続URL"
},
"general": {
"cannot_delete_only_project": "これは唯一のプロジェクトのため削除できません。まず新しいプロジェクトを作成してください。",

View File

@@ -331,7 +331,7 @@
"project_not_found": "Project niet gevonden",
"project_permission_not_found": "Projecttoestemming niet gevonden",
"projects": "Projecten",
"question": "Vraag",
"question": "vraag",
"question_id": "Vraag-ID",
"questions": "Vragen",
"quota": "Quotum",
@@ -801,8 +801,11 @@
"how_to_setup_description": "Volg deze stappen om de Formbricks-widget in uw app in te stellen.",
"receiving_data": "Gegevens ontvangen 💃🕺",
"recheck": "Controleer opnieuw",
"sdk_connection_details": "SDK-verbindingsdetails",
"sdk_connection_details_description": "Uw unieke Environment ID en SDK-verbindings-URL voor integratie van Formbricks met uw applicatie.",
"setup_alert_description": "Volg deze stapsgewijze handleiding om uw app of website in minder dan 5 minuten te verbinden.",
"setup_alert_title": "Hoe te verbinden"
"setup_alert_title": "Hoe te verbinden",
"webapp_url": "SDK-verbindings-URL"
},
"general": {
"cannot_delete_only_project": "Dit is uw enige project. Het kan niet worden verwijderd. Maak eerst een nieuw project aan.",

View File

@@ -331,7 +331,7 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"question": "Pergunta",
"question": "pergunta",
"question_id": "ID da Pergunta",
"questions": "Perguntas",
"quota": "Cota",
@@ -801,8 +801,11 @@
"how_to_setup_description": "Siga esses passos para configurar o widget do Formbricks no seu app.",
"receiving_data": "Recebendo dados 💃🕺",
"recheck": "Verificar novamente",
"sdk_connection_details": "Detalhes de Conexão do SDK",
"sdk_connection_details_description": "Seu ID de ambiente único e URL de conexão do SDK para integrar o Formbricks com seu aplicativo.",
"setup_alert_description": "Siga este tutorial passo a passo para conectar seu app ou site em menos de 5 minutos.",
"setup_alert_title": "Como conectar"
"setup_alert_title": "Como conectar",
"webapp_url": "URL de conexão do SDK"
},
"general": {
"cannot_delete_only_project": "Esse é seu único projeto, não pode ser deletado. Crie um novo projeto primeiro.",

View File

@@ -331,7 +331,7 @@
"project_not_found": "Projeto não encontrado",
"project_permission_not_found": "Permissão do projeto não encontrada",
"projects": "Projetos",
"question": "Pergunta",
"question": "pergunta",
"question_id": "ID da pergunta",
"questions": "Perguntas",
"quota": "Quota",
@@ -801,8 +801,11 @@
"how_to_setup_description": "Siga estes passos para configurar o widget Formbricks na sua aplicação.",
"receiving_data": "A receber dados 💃🕺",
"recheck": "Verificar novamente",
"sdk_connection_details": "Detalhes de Conexão SDK",
"sdk_connection_details_description": "O seu ID de ambiente único e URL de conexão SDK para integrar o Formbricks com a sua aplicação.",
"setup_alert_description": "Siga este tutorial passo a passo para conectar a sua app ou site em menos de 5 minutos",
"setup_alert_title": "Como conectar"
"setup_alert_title": "Como conectar",
"webapp_url": "URL de ligação do SDK"
},
"general": {
"cannot_delete_only_project": "Este é o seu único projeto, não pode ser eliminado. Crie um novo projeto primeiro.",

View File

@@ -331,7 +331,7 @@
"project_not_found": "Proiectul nu a fost găsit",
"project_permission_not_found": "Permisiunea proiectului nu a fost găsită",
"projects": "Proiecte",
"question": "Întrebare",
"question": "întrebare",
"question_id": "ID întrebare",
"questions": "Întrebări",
"quota": "Cotă",
@@ -801,8 +801,11 @@
"how_to_setup_description": "Urmează acești pași pentru a configura widget-ul Formbricks în aplicația ta.",
"receiving_data": "Recepționare date 💃🕺",
"recheck": "Re-verifică",
"sdk_connection_details": "Detalii de conexiune SDK",
"sdk_connection_details_description": "ID-ul mediului tău unic și URL-ul de conexiune SDK pentru a integra Formbricks cu aplicația ta.",
"setup_alert_description": "Urmează acest tutorial pas cu pas pentru a-ți conecta aplicația sau site-ul în mai puțin de 5 minute.",
"setup_alert_title": "Cum să conectezi"
"setup_alert_title": "Cum să conectezi",
"webapp_url": "URL de conexiune SDK"
},
"general": {
"cannot_delete_only_project": "Acesta este singurul tău proiect, nu poate fi șters. Creează mai întâi un proiect nou.",

View File

@@ -801,8 +801,11 @@
"how_to_setup_description": "遵循这些步骤在你的应用中设置 Formbricks 小部件。",
"receiving_data": "接收 数据 💃🕺",
"recheck": "重新检查",
"sdk_connection_details": "SDK 连接详情",
"sdk_connection_details_description": "您唯一的环境 ID 和 SDK 连接 URL用于将 Formbricks 与您的应用程序集成。",
"setup_alert_description": "按照 此 步骤教程 在 5 分钟 以内 连接 你的 应用 或 网站。",
"setup_alert_title": "如何 连接"
"setup_alert_title": "如何 连接",
"webapp_url": "SDK连接URL"
},
"general": {
"cannot_delete_only_project": "这是 您 唯一的 项目,不可 删除。请 先 创建一个新的 项目。",

View File

@@ -801,8 +801,11 @@
"how_to_setup_description": "請按照這些步驟在您的應用程式中設定 Formbricks 小工具。",
"receiving_data": "正在接收資料 💃🕺",
"recheck": "重新檢查",
"sdk_connection_details": "SDK 連線詳細資訊",
"sdk_connection_details_description": "您的唯一環境 ID 和 SDK 連線 URL用於將 Formbricks 與您的應用程式整合。",
"setup_alert_description": "遵循 此 分步 教程 ,在 5 分鐘 內 將您的應用程式 或 網站 連線 。",
"setup_alert_title": "如何 連線"
"setup_alert_title": "如何 連線",
"webapp_url": "SDK 連接 URL"
},
"general": {
"cannot_delete_only_project": "這是您唯一的專案,無法刪除。請先建立新專案。",

View File

@@ -18,6 +18,7 @@ interface QuotaConditionBuilderProps {
conditions: TSurveyQuotaLogic;
onChange: (conditions: TSurveyQuotaLogic) => void;
quotaErrors?: FieldErrors<TSurveyQuotaInput>;
isSubmitted?: boolean;
}
export const QuotaConditionBuilder = ({
@@ -25,6 +26,7 @@ export const QuotaConditionBuilder = ({
conditions,
onChange,
quotaErrors,
isSubmitted,
}: QuotaConditionBuilderProps) => {
const { t } = useTranslation();
@@ -66,6 +68,7 @@ export const QuotaConditionBuilder = ({
config={config}
callbacks={callbacks}
quotaErrors={quotaErrors}
isSubmitted={isSubmitted}
/>
</div>
);

View File

@@ -18,9 +18,13 @@ import {
} from "@formbricks/types/quota";
import { TSurvey } from "@formbricks/types/surveys/types";
import { getFormattedErrorMessage } from "@/lib/utils/helper";
import { replaceHeadlineRecall } from "@/lib/utils/recall";
import { createQuotaAction, updateQuotaAction } from "@/modules/ee/quotas/actions";
import { EndingCardSelector } from "@/modules/ee/quotas/components/ending-card-selector";
import { getDefaultOperatorForQuestion } from "@/modules/survey/editor/lib/utils";
import {
getDefaultOperatorForQuestion,
replaceEndingCardHeadlineRecall,
} from "@/modules/survey/editor/lib/utils";
import { Button } from "@/modules/ui/components/button";
import { ConfirmationModal } from "@/modules/ui/components/confirmation-modal";
import {
@@ -80,6 +84,15 @@ export const QuotaModal = ({
const { t } = useTranslation();
const [openConfirmationModal, setOpenConfirmationModal] = useState(false);
const [openConfirmChangesInInclusionCriteria, setOpenConfirmChangesInInclusionCriteria] = useState(false);
// Transform survey to replace recall: with actual question headlines
const transformedSurvey = useMemo(() => {
let modifiedSurvey = replaceHeadlineRecall(survey, "default");
modifiedSurvey = replaceEndingCardHeadlineRecall(modifiedSurvey, "default");
return modifiedSurvey;
}, [survey]);
const defaultValues = useMemo(() => {
return {
name: quota?.name || "",
@@ -124,7 +137,7 @@ export const QuotaModal = ({
reset,
watch,
control,
formState: { isSubmitting, isDirty, errors, isValid },
formState: { isSubmitting, isDirty, errors, isValid, isSubmitted },
} = form;
// Watch form values for conditional logic
@@ -312,14 +325,17 @@ export const QuotaModal = ({
render={({ field }) => (
<FormItem>
<div className="space-y-4 rounded-lg bg-slate-50 p-3">
<FormLabel>{t("environments.surveys.edit.quotas.inclusion_criteria")}</FormLabel>
<label className="text-sm font-medium text-slate-800">
{t("environments.surveys.edit.quotas.inclusion_criteria")}
</label>
<FormControl>
{field.value && (
<QuotaConditionBuilder
survey={survey}
survey={transformedSurvey}
conditions={field.value}
onChange={handleConditionsChange}
quotaErrors={errors}
isSubmitted={isSubmitted}
/>
)}
</FormControl>

View File

@@ -3,6 +3,7 @@
import Link from "next/link";
import { WidgetStatusIndicator } from "@/app/(app)/environments/[environmentId]/components/WidgetStatusIndicator";
import { SettingsCard } from "@/app/(app)/environments/[environmentId]/settings/components/SettingsCard";
import { WEBAPP_URL } from "@/lib/constants";
import { getActionClasses } from "@/lib/actionClass/service";
import { getEnvironments } from "@/lib/environment/service";
import { findMatchingLocale } from "@/lib/utils/locale";
@@ -40,9 +41,12 @@ export const AppConnectionPage = async ({ params }: { params: Promise<{ environm
<div className="space-y-4">
<EnvironmentNotice environmentId={environmentId} subPageUrl="/project/app-connection" />
<SettingsCard
title={t("environments.project.app-connection.environment_id")}
description={t("environments.project.app-connection.environment_id_description")}>
<IdBadge id={environmentId} />
title={t("environments.project.app-connection.sdk_connection_details")}
description={t("environments.project.app-connection.sdk_connection_details_description")}>
<div className="space-y-3">
<IdBadge id={environmentId} label={t("environments.project.app-connection.environment_id")} />
<IdBadge id={WEBAPP_URL} label={t("environments.project.app-connection.webapp_url")} />
</div>
</SettingsCard>
<SettingsCard
title={t("environments.project.app-connection.app_connection")}

View File

@@ -225,7 +225,7 @@ export const SurveyVariablesCardItem = ({
form.setValue("value", value === "number" ? 0 : "");
field.onChange(value);
}}>
<SelectTrigger className="w-24">
<SelectTrigger className="h-10 w-24">
<SelectValue placeholder={t("environments.surveys.edit.select_type")} />
</SelectTrigger>
<SelectContent>

View File

@@ -74,7 +74,7 @@ export const UpdateQuestionId = ({
disabled={localSurvey.status !== "draft" && !question.isDraft}
className={`h-10 ${isInputInvalid ? "border-red-300 focus:border-red-300" : ""}`}
/>
<Button size="sm" onClick={saveAction} disabled={isButtonDisabled()}>
<Button size="sm" onClick={saveAction} disabled={isButtonDisabled()} className="h-10">
{t("common.save")}
</Button>
</div>

View File

@@ -18,6 +18,7 @@ import { TResponse } from "@formbricks/types/responses";
import { TSurvey } from "@formbricks/types/surveys/types";
import { FB_LOGO_URL, IMPRINT_ADDRESS, IMPRINT_URL, PRIVACY_URL } from "@/lib/constants";
import { getQuestionResponseMapping } from "@/lib/responses";
import { parseRecallInfo } from "@/lib/utils/recall";
import { getTranslate } from "@/lingodotdev/server";
import { renderEmailResponseValue } from "@/modules/email/emails/lib/utils";
@@ -34,7 +35,10 @@ interface FollowUpEmailProps {
export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JSX.Element> {
const { properties } = props.followUp.action;
const { body } = properties;
let { body } = properties;
// Parse recall tags and replace with actual response values
body = parseRecallInfo(body, props.response.data, props.response.variables);
const questions = props.attachResponseData ? getQuestionResponseMapping(props.survey, props.response) : [];
const t = await getTranslate();

View File

@@ -801,6 +801,9 @@ export const FollowUpModal = ({
}
}}
isInvalid={!!formErrors.body}
localSurvey={localSurvey}
questionId="follow-up"
selectedLanguageCode={selectedLanguageCode}
/>
</FormControl>

View File

@@ -35,6 +35,7 @@ interface ConditionsEditorProps {
callbacks: TConditionsEditorCallbacks;
depth?: number;
quotaErrors?: FieldErrors<TSurveyQuotaInput>;
isSubmitted?: boolean;
}
export function ConditionsEditor({
@@ -43,6 +44,7 @@ export function ConditionsEditor({
callbacks,
depth = 0,
quotaErrors,
isSubmitted = false,
}: Readonly<ConditionsEditorProps>) {
const { t } = useTranslation();
const [parent] = useAutoAnimate();
@@ -256,7 +258,7 @@ export function ConditionsEditor({
</DropdownMenuContent>
</DropdownMenu>
</div>
{quotaError && <p className="text-error mt-2 w-full text-right text-sm">{quotaError}</p>}
{quotaError && isSubmitted && <p className="text-error mt-2 w-full text-right text-sm">{quotaError}</p>}
</div>
);
};

View File

@@ -29,7 +29,6 @@
.fb-editor-heading-h1 {
font-size: 25px !important;
font-weight: 400 !important;
margin-bottom: 20px !important;
font-weight: bold !important;
}

View File

@@ -2,9 +2,18 @@
interface EmptyStateProps {
text: string;
variant?: "default" | "simple";
}
export const EmptyState = ({ text }: EmptyStateProps) => {
export const EmptyState = ({ text, variant = "default" }: EmptyStateProps) => {
if (variant === "simple") {
return (
<div className="shadow-xs rounded-xl border border-slate-100 bg-white p-4 text-center">
<p className="text-sm text-slate-500">{text}</p>
</div>
);
}
return (
<div className="shadow-xs rounded-xl border border-slate-100 bg-white p-4">
<div className="w-full space-y-3">