Compare commits

..

5 Commits

Author SHA1 Message Date
Matti Nannt
0b9a884364 fix: smtp missing logs (#4917) 2025-03-11 11:13:22 +00:00
Dhruwang Jariwala
da4211f0b0 fix: Actions for contributors (#4905) 2025-03-10 16:04:17 +00:00
Dhruwang Jariwala
b21827cb32 fix: file input should not allow duplicate files (#4900) 2025-03-10 12:04:03 +00:00
Dhruwang Jariwala
4424a8a21d feat: pt-PT translations (#4874) 2025-03-10 09:22:26 +00:00
Dhruwang Jariwala
eb030f9ed6 fix: Address/Contact question accessibility (#4884) 2025-03-10 09:21:44 +00:00
24 changed files with 352 additions and 94 deletions

View File

@@ -5,7 +5,7 @@ permissions:
on:
workflow_dispatch:
pull_request:
pull_request_target:
types: [opened, synchronize, reopened]
jobs:
@@ -14,6 +14,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.base.ref }}
- name: Checkout PR
uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
- name: Setup Node.js
uses: actions/setup-node@v4

View File

@@ -3,7 +3,7 @@ permissions:
contents: read
on:
pull_request:
pull_request_target:
types: [closed]
branches:
- main
@@ -23,18 +23,16 @@ jobs:
- name: Get source branch name
id: branch-name
run: |
# For PR merges, use the head ref from the pull request event
SOURCE_BRANCH="${{ github.head_ref }}"
RAW_BRANCH="${{ github.head_ref }}"
SOURCE_BRANCH=$(echo "$RAW_BRANCH" | sed 's/[^a-zA-Z0-9._\/-]//g')
# Only remove username prefix if needed
if [[ "$SOURCE_BRANCH" =~ ^[a-zA-Z0-9][a-zA-Z0-9-]+/ ]]; then
PREFIX=${SOURCE_BRANCH%%/*}
if [[ ! "$PREFIX" =~ ^(feature|fix|bugfix|hotfix|release|chore|docs|test|refactor|style|perf|build|ci|revert)$ ]]; then
SOURCE_BRANCH=${SOURCE_BRANCH#*/}
fi
fi
echo "SOURCE_BRANCH=$SOURCE_BRANCH" >> $GITHUB_ENV
# Safely add to environment variables using GitHub's recommended method
# This prevents environment variable injection attacks
echo "SOURCE_BRANCH<<EOF" >> $GITHUB_ENV
echo "$SOURCE_BRANCH" >> $GITHUB_ENV
echo "EOF" >> $GITHUB_ENV
echo "Detected source branch: $SOURCE_BRANCH"
- name: Setup Node.js
@@ -53,6 +51,7 @@ jobs:
--filter-tag "draft:${SOURCE_BRANCH}" \
--tag production \
--untag "draft:${SOURCE_BRANCH}"
--verbose
- name: Tag unused production keys as Deprecated
run: |
@@ -60,6 +59,7 @@ jobs:
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag production \
--tag deprecated --untag production
--verbose
- name: Tag unused draft:current-branch keys as Deprecated
run: |
@@ -67,6 +67,7 @@ jobs:
--api-key ${{ secrets.TOLGEE_API_KEY }} \
--filter-not-extracted --filter-tag "draft:${SOURCE_BRANCH}" \
--tag deprecated --untag "draft:${SOURCE_BRANCH}"
--verbose
- name: Sync with backup
run: |

3
.gitignore vendored
View File

@@ -53,4 +53,5 @@ yarn-error.log*
packages/lib/uploads
apps/web/public/js
packages/database/migrations
branch.json
branch.json
.vercel

View File

@@ -27,6 +27,10 @@
{
"language": "zh-Hant-TW",
"path": "./packages/lib/messages/zh-Hant-TW.json"
},
{
"language": "pt-PT",
"path": "./packages/lib/messages/pt-PT.json"
}
],
"forceMode": "OVERRIDE"

View File

@@ -75,6 +75,7 @@ export const sendEmail = async (emailData: SendEmailDataProps): Promise<boolean>
return true;
} catch (error) {
console.error("Error in sendEmail:", error);
throw new InvalidInputError("Incorrect SMTP credentials");
}
};

View File

@@ -81,7 +81,7 @@ export const QuestionToggleTable = ({
</th>
<th className="w-1/6 text-sm font-semibold">{t("common.show")}</th>
<th className="w-1/6 text-sm font-semibold">{t("environments.surveys.edit.required")}</th>
<th className="text-sm font-semibold">{t("common.placeholder")}</th>
<th className="text-sm font-semibold">{t("common.label")}</th>
</tr>
</thead>
<tbody>

View File

@@ -23,7 +23,7 @@ const nextConfig = {
"app/api/packages": ["../../packages/js-core/dist/*", "../../packages/surveys/dist/*"],
},
i18n: {
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW"],
locales: ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW", "pt-PT"],
localeDetection: false,
defaultLocale: "en-US",
},

View File

@@ -205,22 +205,20 @@ test.describe("Survey Create & Submit Response without logic", async () => {
// Address Question
await expect(page.getByText(surveys.createAndSubmit.address.question)).toBeVisible();
await expect(
page.getByPlaceholder(surveys.createAndSubmit.address.placeholder.addressLine1)
).toBeVisible();
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)).toBeVisible();
await page
.getByPlaceholder(surveys.createAndSubmit.address.placeholder.addressLine1)
.getByLabel(surveys.createAndSubmit.address.placeholder.addressLine1)
.fill("This is my Address");
await expect(page.getByPlaceholder(surveys.createAndSubmit.address.placeholder.city)).toBeVisible();
await page.getByPlaceholder(surveys.createAndSubmit.address.placeholder.city).fill("This is my city");
await expect(page.getByPlaceholder(surveys.createAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByPlaceholder(surveys.createAndSubmit.address.placeholder.zip).fill("12345");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.city)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.city).fill("This is my city");
await expect(page.getByLabel(surveys.createAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.address.placeholder.zip).fill("12345");
await page.locator("#questionCard-10").getByRole("button", { name: "Next" }).click();
// Contact Info Question
await expect(page.getByText(surveys.createAndSubmit.contactInfo.question)).toBeVisible();
await expect(page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder)).toBeVisible();
await page.getByPlaceholder(surveys.createAndSubmit.contactInfo.placeholder).fill("John Doe");
await expect(page.getByLabel(surveys.createAndSubmit.contactInfo.placeholder)).toBeVisible();
await page.getByLabel(surveys.createAndSubmit.contactInfo.placeholder).fill("John Doe");
await page.locator("#questionCard-11").getByRole("button", { name: "Next" }).click();
// Ranking Question
@@ -866,21 +864,17 @@ test.describe("Testing Survey with advanced logic", async () => {
// Address Question
await expect(page.getByText(surveys.createWithLogicAndSubmit.address.question)).toBeVisible();
await expect(
page.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder.addressLine1)
page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.addressLine1)
).toBeVisible();
await page
.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder.addressLine1)
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.addressLine1)
.fill("This is my Address");
await expect(
page.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder.city)
).toBeVisible();
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)).toBeVisible();
await page
.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder.city)
.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.city)
.fill("This is my city");
await expect(
page.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder.zip)
).toBeVisible();
await page.getByPlaceholder(surveys.createWithLogicAndSubmit.address.placeholder.zip).fill("12345");
await expect(page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip)).toBeVisible();
await page.getByLabel(surveys.createWithLogicAndSubmit.address.placeholder.zip).fill("12345");
await page.locator("#questionCard-13").getByRole("button", { name: "Next" }).click();
// loading spinner -> wait for it to disappear

View File

@@ -4,7 +4,7 @@ import { DevTools, Tolgee } from "@tolgee/web";
const apiKey = process.env.NEXT_PUBLIC_TOLGEE_API_KEY;
const apiUrl = process.env.NEXT_PUBLIC_TOLGEE_API_URL;
export const ALL_LANGUAGES = ["en-US", "de-DE", "fr-FR", "pt-BR", "zh-Hant-TW"];
export const ALL_LANGUAGES = ["en-US", "de-DE", "fr-FR", "pt-BR", "pt-PT", "zh-Hant-TW"];
export const DEFAULT_LANGUAGE = "en-US";
@@ -20,6 +20,7 @@ export function TolgeeBase() {
"de-DE": () => import("@formbricks/lib/messages/de-DE.json"),
"fr-FR": () => import("@formbricks/lib/messages/fr-FR.json"),
"pt-BR": () => import("@formbricks/lib/messages/pt-BR.json"),
"pt-PT": () => import("@formbricks/lib/messages/pt-PT.json"),
"zh-Hant-TW": () => import("@formbricks/lib/messages/zh-Hant-TW.json"),
},
});

View File

@@ -214,7 +214,7 @@ export const STRIPE_API_VERSION = "2024-06-20";
export const MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT = 150 as const;
export const DEFAULT_LOCALE = "en-US";
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW"];
export const AVAILABLE_LOCALES: TUserLocale[] = ["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"];
// Billing constants

File diff suppressed because it is too large Load Diff

View File

@@ -1750,9 +1750,7 @@
"how_to_create_a_panel_step_4_description": "Sobald alles eingerichtet ist, kannst Du deine Studie starten. Innerhalb weniger Stunden wirst Du die ersten Antworten erhalten.",
"how_to_embed_a_survey_on_your_react_native_app": "Wie man eine Umfrage in deine React Native App einbettet",
"how_to_embed_a_survey_on_your_web_app": "Wie man eine Umfrage in seine App einbettet",
"identify_users": "Benutzer identifizieren",
"identify_users_and_set_attributes": "Benutzer identifizieren und Attribute festlegen",
"identify_users_description": "Hast Du die E-Mail-Adresse oder eine Benutzer-ID? Füge sie der URL hinzu.",
"impressions": "Eindrücke",
"impressions_tooltip": "Anzahl der Aufrufe der Umfrage.",
"includes_all": "Beinhaltet alles",

View File

@@ -1750,9 +1750,7 @@
"how_to_create_a_panel_step_4_description": "Once everything is setup, you can launch your study. Within a few hours youll receive the first responses.",
"how_to_embed_a_survey_on_your_react_native_app": "How to embed a survey on your React Native app",
"how_to_embed_a_survey_on_your_web_app": "How to embed a survey on your web app",
"identify_users": "Identify users",
"identify_users_and_set_attributes": "identify users and set attributes",
"identify_users_description": "You have the email address or a userId? Append it to the URL.",
"impressions": "Impressions",
"impressions_tooltip": "Number of times the survey has been viewed.",
"includes_all": "Includes all",

View File

@@ -1750,9 +1750,7 @@
"how_to_create_a_panel_step_4_description": "Une fois que tout est configuré, vous pouvez lancer votre étude. Dans quelques heures, vous recevrez les premières réponses.",
"how_to_embed_a_survey_on_your_react_native_app": "Comment intégrer un sondage dans votre application React Native",
"how_to_embed_a_survey_on_your_web_app": "Comment intégrer une enquête dans votre application web",
"identify_users": "Identifier les utilisateurs",
"identify_users_and_set_attributes": "identifier les utilisateurs et définir des attributs",
"identify_users_description": "Avez-vous l'adresse e-mail ou un identifiant utilisateur ? Ajoutez-le à l'URL.",
"impressions": "Impressions",
"impressions_tooltip": "Nombre de fois que l'enquête a été consultée.",
"includes_all": "Comprend tous",

View File

@@ -1750,9 +1750,7 @@
"how_to_create_a_panel_step_4_description": "Depois que tudo estiver configurado, você pode iniciar seu estudo. Em algumas horas, você vai receber as primeiras respostas.",
"how_to_embed_a_survey_on_your_react_native_app": "Como incorporar uma pesquisa no seu app React Native",
"how_to_embed_a_survey_on_your_web_app": "Como incorporar uma pesquisa no seu app web",
"identify_users": "Identificar usuários",
"identify_users_and_set_attributes": "identificar usuários e definir atributos",
"identify_users_description": "Você tem o endereço de e-mail ou um userId? Adiciona isso ao URL.",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que a pesquisa foi visualizada.",
"includes_all": "Inclui tudo",

View File

@@ -1750,9 +1750,7 @@
"how_to_create_a_panel_step_4_description": "Depois de tudo configurado, pode lançar o seu estudo. Dentro de algumas horas, receberá as primeiras respostas.",
"how_to_embed_a_survey_on_your_react_native_app": "Como incorporar um questionário na sua aplicação React Native",
"how_to_embed_a_survey_on_your_web_app": "Como incorporar um questionário na sua aplicação web",
"identify_users": "Identificar utilizadores",
"identify_users_and_set_attributes": "identificar utilizadores e definir atributos",
"identify_users_description": "Tem o endereço de email ou um userId? Adicione-o ao URL.",
"impressions": "Impressões",
"impressions_tooltip": "Número de vezes que o inquérito foi visualizado.",
"includes_all": "Inclui tudo",

View File

@@ -1750,9 +1750,7 @@
"how_to_create_a_panel_step_4_description": "設定完成後,您可以啟動您的研究。在幾個小時內,您就會收到第一個回應。",
"how_to_embed_a_survey_on_your_react_native_app": "如何在您的 React Native 應用程式中嵌入問卷",
"how_to_embed_a_survey_on_your_web_app": "如何在您的 Web 應用程式中嵌入問卷",
"identify_users": "識別使用者",
"identify_users_and_set_attributes": "識別使用者並設定屬性",
"identify_users_description": "您有電子郵件地址或使用者 ID 嗎?將其附加到網址。",
"impressions": "曝光數",
"impressions_tooltip": "問卷已檢視的次數。",
"includes_all": "包含全部",

View File

@@ -1,5 +1,5 @@
import { formatDistance, intlFormat } from "date-fns";
import { de, enUS, fr, ptBR, zhTW } from "date-fns/locale";
import { de, enUS, fr, pt, ptBR, zhTW } from "date-fns/locale";
import { TUserLocale } from "@formbricks/types/user";
export const convertDateString = (dateString: string) => {
@@ -88,6 +88,8 @@ const getLocaleForTimeSince = (locale: TUserLocale) => {
return fr;
case "zh-Hant-TW":
return zhTW;
case "pt-PT":
return pt;
}
};

View File

@@ -36,6 +36,36 @@ export function FileInput({
const [isUploading, setIsUploading] = useState(false);
const [parent] = useAutoAnimate();
// Helper function to filter duplicate files
const filterDuplicateFiles = <T extends { name: string }>(
files: T[],
checkAgainstSelected: boolean = true
): {
filteredFiles: T[];
duplicateFiles: T[];
} => {
const existingFileNames = fileUrls ? fileUrls.map(getOriginalFileNameFromUrl) : [];
const duplicateFiles = files.filter(
(file) =>
existingFileNames.includes(file.name) ||
(checkAgainstSelected && selectedFiles.some((selectedFile) => selectedFile.name === file.name))
);
const filteredFiles = files.filter(
(file) =>
!existingFileNames.includes(file.name) &&
(!checkAgainstSelected || !selectedFiles.some((selectedFile) => selectedFile.name === file.name))
);
if (duplicateFiles.length > 0) {
const duplicateNames = duplicateFiles.map((file) => file.name).join(", ");
alert(`The following files are already uploaded: ${duplicateNames}. Duplicate files are not allowed.`);
}
return { filteredFiles, duplicateFiles };
};
// Listen for the native file-upload event dispatched via window.formbricksSurveys.onFilePick
useEffect(() => {
const handleNativeFileUpload = async (
@@ -47,7 +77,7 @@ export function FileInput({
setIsUploading(true);
// Filter out files that exceed the maximum size
const filteredFiles: typeof filesFromNative = [];
let filteredFiles: typeof filesFromNative = [];
const rejectedFiles: string[] = [];
if (maxSizeInMB) {
@@ -67,6 +97,10 @@ export function FileInput({
filteredFiles.push(...filesFromNative);
}
// Check for duplicate files - native uploads don't need to check against selectedFiles
const { filteredFiles: nonDuplicateFiles } = filterDuplicateFiles(filteredFiles, false);
filteredFiles = nonDuplicateFiles;
// Display alert for rejected files
if (rejectedFiles.length > 0) {
const fileNames = rejectedFiles.join(", ");
@@ -113,7 +147,7 @@ export function FileInput({
};
const handleFileSelection = async (files: FileList) => {
const fileArray = Array.from(files);
let fileArray = Array.from(files);
if (!allowMultipleFiles && fileArray.length > 1) {
alert("Only one file can be uploaded at a time.");
@@ -125,8 +159,17 @@ export function FileInput({
return;
}
// Check for duplicate files
const { filteredFiles: nonDuplicateFiles } = filterDuplicateFiles(fileArray);
if (nonDuplicateFiles.length === 0) {
return; // No non-duplicate files to process
}
fileArray = nonDuplicateFiles;
// filter out files that are not allowed
const validFiles = Array.from(files).filter((file) => {
const validFiles = fileArray.filter((file) => {
const fileExtension = file.type.substring(file.type.lastIndexOf("/") + 1) as TAllowedFileExtension;
if (allowedFileExtensions) {
return allowedFileExtensions.includes(fileExtension);

View File

@@ -0,0 +1,7 @@
interface LabelProps {
text: string;
}
export function Label({ text }: LabelProps) {
return <label className="fb-text-subheading fb-font-normal fb-text-sm">{text}</label>;
}

View File

@@ -2,6 +2,7 @@ import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { Input } from "@/components/general/input";
import { Label } from "@/components/general/label";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
@@ -56,32 +57,32 @@ export function AddressQuestion({
{
id: "addressLine1",
...question.addressLine1,
placeholder: question.addressLine1.placeholder[languageCode],
label: question.addressLine1.placeholder[languageCode],
},
{
id: "addressLine2",
...question.addressLine2,
placeholder: question.addressLine2.placeholder[languageCode],
label: question.addressLine2.placeholder[languageCode],
},
{
id: "city",
...question.city,
placeholder: question.city.placeholder[languageCode],
label: question.city.placeholder[languageCode],
},
{
id: "state",
...question.state,
placeholder: question.state.placeholder[languageCode],
label: question.state.placeholder[languageCode],
},
{
id: "zip",
...question.zip,
placeholder: question.zip.placeholder[languageCode],
label: question.zip.placeholder[languageCode],
},
{
id: "country",
...question.country,
placeholder: question.country.placeholder[languageCode],
label: question.country.placeholder[languageCode],
},
];
@@ -155,19 +156,21 @@ export function AddressQuestion({
return (
field.show && (
<Input
key={field.id}
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
required={isFieldRequired()}
value={safeValue[index] || ""}
className="fb-py-3"
type={field.id === "email" ? "email" : "text"}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
ref={index === 0 ? addressRef : null}
tabIndex={isCurrent ? 0 : -1}
/>
<div className="fb-space-y-1">
<Label text={isFieldRequired() ? `${field.label}*` : field.label} />
<Input
key={field.id}
required={isFieldRequired()}
value={safeValue[index] || ""}
type={field.id === "email" ? "email" : "text"}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
ref={index === 0 ? addressRef : null}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
/>
</div>
)
);
})}

View File

@@ -2,6 +2,7 @@ import { BackButton } from "@/components/buttons/back-button";
import { SubmitButton } from "@/components/buttons/submit-button";
import { Headline } from "@/components/general/headline";
import { Input } from "@/components/general/input";
import { Label } from "@/components/general/label";
import { QuestionMedia } from "@/components/general/question-media";
import { Subheader } from "@/components/general/subheader";
import { ScrollableContainer } from "@/components/wrappers/scrollable-container";
@@ -56,27 +57,27 @@ export function ContactInfoQuestion({
{
id: "firstName",
...question.firstName,
placeholder: question.firstName.placeholder[languageCode],
label: question.firstName.placeholder[languageCode],
},
{
id: "lastName",
...question.lastName,
placeholder: question.lastName.placeholder[languageCode],
label: question.lastName.placeholder[languageCode],
},
{
id: "email",
...question.email,
placeholder: question.email.placeholder[languageCode],
label: question.email.placeholder[languageCode],
},
{
id: "phone",
...question.phone,
placeholder: question.phone.placeholder[languageCode],
label: question.phone.placeholder[languageCode],
},
{
id: "company",
...question.company,
placeholder: question.company.placeholder[languageCode],
label: question.company.placeholder[languageCode],
},
];
@@ -157,19 +158,21 @@ export function ContactInfoQuestion({
return (
field.show && (
<Input
ref={index === 0 ? contactInfoRef : null}
key={field.id}
placeholder={isFieldRequired() ? `${field.placeholder}*` : field.placeholder}
required={isFieldRequired()}
value={safeValue[index] || ""}
className="fb-py-3"
type={inputType}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={isCurrent ? 0 : -1}
/>
<div className="fb-space-y-1">
<Label text={isFieldRequired() ? `${field.label}*` : field.label} />
<Input
ref={index === 0 ? contactInfoRef : null}
key={field.id}
required={isFieldRequired()}
value={safeValue[index] || ""}
type={inputType}
onChange={(e) => {
handleChange(field.id, e.currentTarget.value);
}}
tabIndex={isCurrent ? 0 : -1}
aria-label={field.label}
/>
</div>
)
);
})}

View File

@@ -1168,7 +1168,7 @@ export const ZSurvey = z
const multiLangIssueInPlaceholder =
field.show &&
validateQuestionLabels(
`Placeholder for field ${field.label}`,
`Label for field ${field.label}`,
field.placeholder,
languages,
questionIndex,
@@ -1202,7 +1202,7 @@ export const ZSurvey = z
const multiLangIssueInPlaceholder =
field.show &&
validateQuestionLabels(
`Placeholder for field ${field.label}`,
`Label for field ${field.label}`,
field.placeholder,
languages,
questionIndex,

View File

@@ -2,7 +2,7 @@ import { z } from "zod";
const ZRole = z.enum(["project_manager", "engineer", "founder", "marketing_specialist", "other"]);
export const ZUserLocale = z.enum(["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW"]);
export const ZUserLocale = z.enum(["en-US", "de-DE", "pt-BR", "fr-FR", "zh-Hant-TW", "pt-PT"]);
export type TUserLocale = z.infer<typeof ZUserLocale>;
export const ZUserObjective = z.enum([