mirror of
https://github.com/formbricks/formbricks.git
synced 2026-01-01 03:11:44 -06:00
Compare commits
11 Commits
fix/user-a
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d12ef3ef4e | ||
|
|
7260a1a3a4 | ||
|
|
5b308d10dc | ||
|
|
68f1f42f81 | ||
|
|
086d8177dc | ||
|
|
efbe27fa95 | ||
|
|
ca1a0053b8 | ||
|
|
035093e702 | ||
|
|
75d33a1716 | ||
|
|
97ab194107 | ||
|
|
e9cc636510 |
@@ -26,7 +26,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
|
|||||||
|
|
||||||
if (vimeoUrl.protocol !== "https:") return false;
|
if (vimeoUrl.protocol !== "https:") return false;
|
||||||
|
|
||||||
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
|
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
|
||||||
const hostname = vimeoUrl.hostname;
|
const hostname = vimeoUrl.hostname;
|
||||||
|
|
||||||
return vimeoDomains.includes(hostname);
|
return vimeoDomains.includes(hostname);
|
||||||
@@ -74,7 +74,7 @@ export const extractYoutubeId = (url: string): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const extractVimeoId = (url: string): string | null => {
|
export const extractVimeoId = (url: string): string | null => {
|
||||||
const regExp = /vimeo\.com\/(\d+)/;
|
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
|
||||||
const match = regExp.exec(url);
|
const match = regExp.exec(url);
|
||||||
|
|
||||||
if (match?.[1]) {
|
if (match?.[1]) {
|
||||||
@@ -85,7 +85,7 @@ export const extractVimeoId = (url: string): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const extractLoomId = (url: string): string | null => {
|
export const extractLoomId = (url: string): string | null => {
|
||||||
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
|
const regExp = /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/;
|
||||||
const match = regExp.exec(url);
|
const match = regExp.exec(url);
|
||||||
|
|
||||||
if (match?.[1]) {
|
if (match?.[1]) {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ interface EditWelcomeCardProps {
|
|||||||
setSelectedLanguageCode: (languageCode: string) => void;
|
setSelectedLanguageCode: (languageCode: string) => void;
|
||||||
locale: TUserLocale;
|
locale: TUserLocale;
|
||||||
isStorageConfigured: boolean;
|
isStorageConfigured: boolean;
|
||||||
|
isExternalUrlsAllowed?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EditWelcomeCard = ({
|
export const EditWelcomeCard = ({
|
||||||
@@ -34,6 +35,7 @@ export const EditWelcomeCard = ({
|
|||||||
setSelectedLanguageCode,
|
setSelectedLanguageCode,
|
||||||
locale,
|
locale,
|
||||||
isStorageConfigured = true,
|
isStorageConfigured = true,
|
||||||
|
isExternalUrlsAllowed,
|
||||||
}: EditWelcomeCardProps) => {
|
}: EditWelcomeCardProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ export const EditWelcomeCard = ({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
open ? "bg-slate-50" : "",
|
open ? "bg-slate-50" : "",
|
||||||
"flex w-10 items-center justify-center rounded-l-lg border-b border-l border-t group-aria-expanded:rounded-bl-none",
|
"flex w-10 items-center justify-center rounded-l-lg border-t border-b border-l group-aria-expanded:rounded-bl-none",
|
||||||
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
isInvalid ? "bg-red-400" : "bg-white group-hover:bg-slate-50"
|
||||||
)}>
|
)}>
|
||||||
<Hand className="h-4 w-4" />
|
<Hand className="h-4 w-4" />
|
||||||
@@ -135,6 +137,7 @@ export const EditWelcomeCard = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
@@ -150,6 +153,7 @@ export const EditWelcomeCard = ({
|
|||||||
setSelectedLanguageCode={setSelectedLanguageCode}
|
setSelectedLanguageCode={setSelectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -170,6 +174,7 @@ export const EditWelcomeCard = ({
|
|||||||
label={t("environments.surveys.edit.next_button_label")}
|
label={t("environments.surveys.edit.next_button_label")}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -808,6 +808,7 @@ export const ElementsView = ({
|
|||||||
selectedLanguageCode={selectedLanguageCode}
|
selectedLanguageCode={selectedLanguageCode}
|
||||||
locale={locale}
|
locale={locale}
|
||||||
isStorageConfigured={isStorageConfigured}
|
isStorageConfigured={isStorageConfigured}
|
||||||
|
isExternalUrlsAllowed={isExternalUrlsAllowed}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Column, Hr, Row, Text } from "@react-email/components";
|
import { Column, Hr, Row, Text } from "@react-email/components";
|
||||||
import dompurify from "isomorphic-dompurify";
|
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import sanitizeHtml from "sanitize-html";
|
||||||
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
|
import { TSurveyFollowUp } from "@formbricks/database/types/survey-follow-up";
|
||||||
import { TResponse } from "@formbricks/types/responses";
|
import { TResponse } from "@formbricks/types/responses";
|
||||||
import { TSurvey } from "@formbricks/types/surveys/types";
|
import { TSurvey } from "@formbricks/types/surveys/types";
|
||||||
@@ -35,11 +35,16 @@ export async function FollowUpEmail(props: FollowUpEmailProps): Promise<React.JS
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: dompurify.sanitize(body, {
|
__html: sanitizeHtml(body, {
|
||||||
ALLOWED_TAGS: ["p", "span", "b", "strong", "i", "em", "a", "br"],
|
allowedTags: ["p", "span", "b", "strong", "i", "em", "a", "br"],
|
||||||
ALLOWED_ATTR: ["href", "rel", "dir", "class"],
|
allowedAttributes: {
|
||||||
ALLOWED_URI_REGEXP: /^https?:\/\//, // Only allow safe URLs starting with http or https
|
a: ["href", "rel", "target"],
|
||||||
ADD_ATTR: ["target"], // Optional: Allow 'target' attribute for links (e.g., _blank)
|
"*": ["dir", "class"],
|
||||||
|
},
|
||||||
|
allowedSchemes: ["http", "https"],
|
||||||
|
allowedSchemesByTag: {
|
||||||
|
a: ["http", "https"],
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ export const getAllowedFiles = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const checkForYoutubePrivacyMode = (url: string): boolean => {
|
export const checkForYoutubePrivacyMode = (url: string): boolean => {
|
||||||
|
if (!url || typeof url !== "string" || url.trim() === "") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsedUrl = new URL(url);
|
const parsedUrl = new URL(url);
|
||||||
return parsedUrl.host === "www.youtube-nocookie.com";
|
return parsedUrl.host === "www.youtube-nocookie.com";
|
||||||
|
|||||||
@@ -72,8 +72,8 @@
|
|||||||
"@radix-ui/react-tooltip": "1.2.6",
|
"@radix-ui/react-tooltip": "1.2.6",
|
||||||
"@react-email/components": "0.0.38",
|
"@react-email/components": "0.0.38",
|
||||||
"@sentry/nextjs": "10.5.0",
|
"@sentry/nextjs": "10.5.0",
|
||||||
"@tailwindcss/forms": "0.5.10",
|
|
||||||
"@t3-oss/env-nextjs": "0.13.4",
|
"@t3-oss/env-nextjs": "0.13.4",
|
||||||
|
"@tailwindcss/forms": "0.5.10",
|
||||||
"@tailwindcss/typography": "0.5.16",
|
"@tailwindcss/typography": "0.5.16",
|
||||||
"@tanstack/react-table": "8.21.3",
|
"@tanstack/react-table": "8.21.3",
|
||||||
"@ungap/structured-clone": "1.3.0",
|
"@ungap/structured-clone": "1.3.0",
|
||||||
@@ -111,16 +111,17 @@
|
|||||||
"prismjs": "1.30.0",
|
"prismjs": "1.30.0",
|
||||||
"qr-code-styling": "1.9.2",
|
"qr-code-styling": "1.9.2",
|
||||||
"qrcode": "1.5.4",
|
"qrcode": "1.5.4",
|
||||||
|
"react-calendar": "5.1.0",
|
||||||
"react-colorful": "5.6.1",
|
"react-colorful": "5.6.1",
|
||||||
"react-confetti": "6.4.0",
|
"react-confetti": "6.4.0",
|
||||||
"react-day-picker": "9.6.7",
|
"react-day-picker": "9.6.7",
|
||||||
"react-hook-form": "7.56.2",
|
"react-hook-form": "7.56.2",
|
||||||
"react-hot-toast": "2.5.2",
|
"react-hot-toast": "2.5.2",
|
||||||
"react-calendar": "5.1.0",
|
|
||||||
"react-i18next": "15.7.3",
|
"react-i18next": "15.7.3",
|
||||||
"react-turnstile": "1.1.4",
|
"react-turnstile": "1.1.4",
|
||||||
"react-use": "17.6.0",
|
"react-use": "17.6.0",
|
||||||
"redis": "4.7.0",
|
"redis": "4.7.0",
|
||||||
|
"sanitize-html": "2.17.0",
|
||||||
"server-only": "0.0.1",
|
"server-only": "0.0.1",
|
||||||
"sharp": "0.34.1",
|
"sharp": "0.34.1",
|
||||||
"stripe": "16.12.0",
|
"stripe": "16.12.0",
|
||||||
@@ -148,6 +149,7 @@
|
|||||||
"@types/nodemailer": "7.0.2",
|
"@types/nodemailer": "7.0.2",
|
||||||
"@types/papaparse": "5.3.15",
|
"@types/papaparse": "5.3.15",
|
||||||
"@types/qrcode": "1.5.5",
|
"@types/qrcode": "1.5.5",
|
||||||
|
"@types/sanitize-html": "2.16.0",
|
||||||
"@types/testing-library__react": "10.2.0",
|
"@types/testing-library__react": "10.2.0",
|
||||||
"@types/ungap__structured-clone": "1.2.0",
|
"@types/ungap__structured-clone": "1.2.0",
|
||||||
"@vitest/coverage-v8": "3.1.3",
|
"@vitest/coverage-v8": "3.1.3",
|
||||||
|
|||||||
@@ -30,6 +30,10 @@ export interface ConsentProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
/** Whether the checkbox is disabled */
|
/** Whether the checkbox is disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Consent({
|
function Consent({
|
||||||
@@ -44,6 +48,8 @@ function Consent({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<ConsentProps>): React.JSX.Element {
|
}: Readonly<ConsentProps>): React.JSX.Element {
|
||||||
const handleCheckboxChange = (checked: boolean): void => {
|
const handleCheckboxChange = (checked: boolean): void => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
@@ -53,7 +59,14 @@ function Consent({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Consent Checkbox */}
|
{/* Consent Checkbox */}
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
@@ -74,7 +87,7 @@ function Consent({
|
|||||||
onCheckedChange={handleCheckboxChange}
|
onCheckedChange={handleCheckboxChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-invalid={Boolean(errorMessage)}
|
aria-invalid={Boolean(errorMessage)}
|
||||||
aria-required={required}
|
required={required}
|
||||||
/>
|
/>
|
||||||
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
|
{/* need to use style here because tailwind is not able to use css variables for font size and weight */}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ export interface CTAProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Variant for the button */
|
/** Variant for the button */
|
||||||
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
buttonVariant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link" | "custom";
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function CTA({
|
function CTA({
|
||||||
@@ -50,6 +54,8 @@ function CTA({
|
|||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
buttonVariant = "default",
|
buttonVariant = "default",
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<CTAProps>): React.JSX.Element {
|
}: Readonly<CTAProps>): React.JSX.Element {
|
||||||
const handleButtonClick = (): void => {
|
const handleButtonClick = (): void => {
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
@@ -63,24 +69,33 @@ function CTA({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* CTA Button */}
|
{/* CTA Button */}
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
<ElementError errorMessage={errorMessage} dir={dir} />
|
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||||
|
|
||||||
<div className="flex w-full justify-start">
|
{buttonExternal && (
|
||||||
<Button
|
<div className="flex w-full justify-start">
|
||||||
id={inputId}
|
<Button
|
||||||
type="button"
|
id={inputId}
|
||||||
onClick={handleButtonClick}
|
type="button"
|
||||||
disabled={disabled}
|
onClick={handleButtonClick}
|
||||||
className="flex items-center gap-2"
|
disabled={disabled}
|
||||||
variant={buttonVariant}>
|
className="flex items-center gap-2"
|
||||||
{buttonLabel}
|
variant={buttonVariant}>
|
||||||
{buttonExternal ? <SquareArrowOutUpRightIcon className="size-4" /> : null}
|
{buttonLabel}
|
||||||
</Button>
|
<SquareArrowOutUpRightIcon className="size-4" />
|
||||||
</div>
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Calendar } from "@/components/general/calendar";
|
import { Calendar } from "@/components/general/calendar";
|
||||||
|
import { ElementError } from "@/components/general/element-error";
|
||||||
import { ElementHeader } from "@/components/general/element-header";
|
import { ElementHeader } from "@/components/general/element-header";
|
||||||
import { getDateFnsLocale } from "@/lib/locale";
|
import { getDateFnsLocale } from "@/lib/locale";
|
||||||
|
|
||||||
@@ -30,6 +31,10 @@ interface DateElementProps {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
|
/** Locale code for date formatting (e.g., "en-US", "de-DE", "fr-FR"). Defaults to browser locale or "en-US" */
|
||||||
locale?: string;
|
locale?: string;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DateElement({
|
function DateElement({
|
||||||
@@ -45,6 +50,9 @@ function DateElement({
|
|||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
locale = "en-US",
|
locale = "en-US",
|
||||||
|
errorMessage,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<DateElementProps>): React.JSX.Element {
|
}: Readonly<DateElementProps>): React.JSX.Element {
|
||||||
// Initialize date from value string, parsing as local time to avoid timezone issues
|
// Initialize date from value string, parsing as local time to avoid timezone issues
|
||||||
const [date, setDate] = React.useState<Date | undefined>(() => {
|
const [date, setDate] = React.useState<Date | undefined>(() => {
|
||||||
@@ -87,58 +95,86 @@ function DateElement({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Convert minDate/maxDate strings to Date objects
|
|
||||||
const minDateObj = minDate ? new Date(minDate) : undefined;
|
|
||||||
const maxDateObj = maxDate ? new Date(maxDate) : undefined;
|
|
||||||
|
|
||||||
// Create disabled function for date restrictions
|
|
||||||
const isDateDisabled = React.useCallback(
|
|
||||||
(dateToCheck: Date): boolean => {
|
|
||||||
if (disabled) return true;
|
|
||||||
if (minDateObj) {
|
|
||||||
const minAtMidnight = new Date(minDateObj.getFullYear(), minDateObj.getMonth(), minDateObj.getDate());
|
|
||||||
const checkAtMidnight = new Date(
|
|
||||||
dateToCheck.getFullYear(),
|
|
||||||
dateToCheck.getMonth(),
|
|
||||||
dateToCheck.getDate()
|
|
||||||
);
|
|
||||||
if (checkAtMidnight < minAtMidnight) return true;
|
|
||||||
}
|
|
||||||
if (maxDateObj) {
|
|
||||||
const maxAtMidnight = new Date(maxDateObj.getFullYear(), maxDateObj.getMonth(), maxDateObj.getDate());
|
|
||||||
const checkAtMidnight = new Date(
|
|
||||||
dateToCheck.getFullYear(),
|
|
||||||
dateToCheck.getMonth(),
|
|
||||||
dateToCheck.getDate()
|
|
||||||
);
|
|
||||||
if (checkAtMidnight > maxAtMidnight) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
[disabled, minDateObj, maxDateObj]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Get locale for date formatting
|
// Get locale for date formatting
|
||||||
const dateLocale = React.useMemo(() => {
|
const dateLocale = React.useMemo(() => {
|
||||||
return locale ? getDateFnsLocale(locale) : undefined;
|
return locale ? getDateFnsLocale(locale) : undefined;
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
const startMonth = React.useMemo(() => {
|
||||||
|
if (!minDate) return undefined;
|
||||||
|
try {
|
||||||
|
const [year, month, day] = minDate.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [minDate]);
|
||||||
|
|
||||||
|
const endMonth = React.useMemo(() => {
|
||||||
|
if (!maxDate) return undefined;
|
||||||
|
try {
|
||||||
|
const [year, month, day] = maxDate.split("-").map(Number);
|
||||||
|
return new Date(year, month - 1, day);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}, [maxDate]);
|
||||||
|
|
||||||
|
// Create disabled function for date restrictions
|
||||||
|
const isDateDisabled = React.useCallback(
|
||||||
|
(dateToCheck: Date): boolean => {
|
||||||
|
if (disabled) return true;
|
||||||
|
|
||||||
|
const checkAtMidnight = new Date(
|
||||||
|
dateToCheck.getFullYear(),
|
||||||
|
dateToCheck.getMonth(),
|
||||||
|
dateToCheck.getDate()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (startMonth) {
|
||||||
|
const minAtMidnight = new Date(startMonth.getFullYear(), startMonth.getMonth(), startMonth.getDate());
|
||||||
|
if (checkAtMidnight < minAtMidnight) return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endMonth) {
|
||||||
|
const maxAtMidnight = new Date(endMonth.getFullYear(), endMonth.getMonth(), endMonth.getDate());
|
||||||
|
if (checkAtMidnight > maxAtMidnight) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
[disabled, endMonth, startMonth]
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Calendar - Always visible */}
|
<div className="relative">
|
||||||
<div className="w-full">
|
<ElementError errorMessage={errorMessage} dir={dir} />
|
||||||
<Calendar
|
{/* Calendar - Always visible */}
|
||||||
mode="single"
|
<div className="w-full">
|
||||||
selected={date}
|
<Calendar
|
||||||
captionLayout="dropdown"
|
mode="single"
|
||||||
disabled={isDateDisabled}
|
selected={date}
|
||||||
onSelect={handleDateSelect}
|
defaultMonth={date}
|
||||||
locale={dateLocale}
|
captionLayout="dropdown"
|
||||||
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
|
startMonth={startMonth}
|
||||||
/>
|
endMonth={endMonth}
|
||||||
|
disabled={isDateDisabled}
|
||||||
|
onSelect={handleDateSelect}
|
||||||
|
locale={dateLocale}
|
||||||
|
required={required}
|
||||||
|
className="rounded-input border-input-border bg-input-bg text-input-text shadow-input mx-auto w-full max-w-[25rem] border"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -236,7 +236,6 @@ export const MultipleElements: Story = {
|
|||||||
description="You can upload multiple images"
|
description="You can upload multiple images"
|
||||||
allowMultiple
|
allowMultiple
|
||||||
allowedFileExtensions={[".jpg", ".png", ".gif"]}
|
allowedFileExtensions={[".jpg", ".png", ".gif"]}
|
||||||
maxSizeInMB={5}
|
|
||||||
onChange={() => {}}
|
onChange={() => {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Upload, UploadIcon, X } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { ElementError } from "@/components/general/element-error";
|
import { ElementError } from "@/components/general/element-error";
|
||||||
import { ElementHeader } from "@/components/general/element-header";
|
import { ElementHeader } from "@/components/general/element-header";
|
||||||
import { Input } from "@/components/general/input";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -146,6 +145,7 @@ interface UploadAreaProps {
|
|||||||
onDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
|
onDragOver: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||||
onDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
|
onDrop: (e: React.DragEvent<HTMLLabelElement>) => void;
|
||||||
showUploader: boolean;
|
showUploader: boolean;
|
||||||
|
uploadedFiles: UploadedFile[];
|
||||||
}
|
}
|
||||||
|
|
||||||
function UploadArea({
|
function UploadArea({
|
||||||
@@ -161,6 +161,7 @@ function UploadArea({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
showUploader,
|
showUploader,
|
||||||
|
uploadedFiles,
|
||||||
}: Readonly<UploadAreaProps>): React.JSX.Element | null {
|
}: Readonly<UploadAreaProps>): React.JSX.Element | null {
|
||||||
if (!showUploader) {
|
if (!showUploader) {
|
||||||
return null;
|
return null;
|
||||||
@@ -193,16 +194,16 @@ function UploadArea({
|
|||||||
id={`${inputId}-label`}>
|
id={`${inputId}-label`}>
|
||||||
{placeholderText}
|
{placeholderText}
|
||||||
</span>
|
</span>
|
||||||
<Input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
id={inputId}
|
id={inputId}
|
||||||
className="hidden"
|
className="sr-only"
|
||||||
multiple={allowMultiple}
|
multiple={allowMultiple}
|
||||||
accept={acceptAttribute}
|
accept={acceptAttribute}
|
||||||
onChange={onFileChange}
|
onChange={onFileChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required={required}
|
required={uploadedFiles.length > 0 ? false : required}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
aria-label="File upload"
|
aria-label="File upload"
|
||||||
aria-describedby={`${inputId}-label`}
|
aria-describedby={`${inputId}-label`}
|
||||||
@@ -324,6 +325,7 @@ function FileUpload({
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
showUploader={showUploader}
|
showUploader={showUploader}
|
||||||
|
uploadedFiles={uploadedFiles}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ interface FormFieldProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
/** Whether the controls are disabled */
|
/** Whether the controls are disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormField({
|
function FormField({
|
||||||
@@ -56,6 +60,8 @@ function FormField({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<FormFieldProps>): React.JSX.Element {
|
}: Readonly<FormFieldProps>): React.JSX.Element {
|
||||||
// Ensure value is always an object
|
// Ensure value is always an object
|
||||||
const currentValues = React.useMemo(() => {
|
const currentValues = React.useMemo(() => {
|
||||||
@@ -93,7 +99,13 @@ function FormField({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Form Fields */}
|
{/* Form Fields */}
|
||||||
<div className="relative space-y-3">
|
<div className="relative space-y-3">
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ interface MatrixProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
/** Whether the options are disabled */
|
/** Whether the options are disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Matrix({
|
function Matrix({
|
||||||
@@ -56,6 +60,8 @@ function Matrix({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<MatrixProps>): React.JSX.Element {
|
}: Readonly<MatrixProps>): React.JSX.Element {
|
||||||
// Ensure value is always an object (value already has default of {})
|
// Ensure value is always an object (value already has default of {})
|
||||||
const selectedValues = value;
|
const selectedValues = value;
|
||||||
@@ -78,7 +84,14 @@ function Matrix({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Matrix Table */}
|
{/* Matrix Table */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
@@ -114,6 +127,7 @@ function Matrix({
|
|||||||
onValueChange={(newColumnId) => {
|
onValueChange={(newColumnId) => {
|
||||||
handleRowChange(row.id, newColumnId);
|
handleRowChange(row.id, newColumnId);
|
||||||
}}
|
}}
|
||||||
|
name={rowGroupId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
required={required}
|
required={required}
|
||||||
aria-invalid={Boolean(errorMessage)}>
|
aria-invalid={Boolean(errorMessage)}>
|
||||||
@@ -142,6 +156,7 @@ function Matrix({
|
|||||||
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
|
<Label htmlFor={cellId} className="flex cursor-pointer justify-center">
|
||||||
<RadioGroupItem
|
<RadioGroupItem
|
||||||
value={column.id}
|
value={column.id}
|
||||||
|
required={required}
|
||||||
id={cellId}
|
id={cellId}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
aria-label={`${row.label}-${column.label}`}
|
aria-label={`${row.label}-${column.label}`}
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ interface MultiSelectProps {
|
|||||||
onOtherValueChange?: (value: string) => void;
|
onOtherValueChange?: (value: string) => void;
|
||||||
/** IDs of options that should be exclusive (selecting them deselects all others) */
|
/** IDs of options that should be exclusive (selecting them deselects all others) */
|
||||||
exclusiveOptionIds?: string[];
|
exclusiveOptionIds?: string[];
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared className for option labels
|
// Shared className for option labels
|
||||||
@@ -137,7 +141,7 @@ function DropdownVariant({
|
|||||||
|
|
||||||
const isRequired = getIsRequired();
|
const isRequired = getIsRequired();
|
||||||
|
|
||||||
const handleOptionToggle = (optionId: string) => {
|
const handleOptionToggle = (optionId: string): void => {
|
||||||
if (selectedValues.includes(optionId)) {
|
if (selectedValues.includes(optionId)) {
|
||||||
handleOptionRemove(optionId);
|
handleOptionRemove(optionId);
|
||||||
} else {
|
} else {
|
||||||
@@ -440,6 +444,8 @@ function MultiSelect({
|
|||||||
otherValue = "",
|
otherValue = "",
|
||||||
onOtherValueChange,
|
onOtherValueChange,
|
||||||
exclusiveOptionIds = [],
|
exclusiveOptionIds = [],
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<MultiSelectProps>): React.JSX.Element {
|
}: Readonly<MultiSelectProps>): React.JSX.Element {
|
||||||
// Ensure value is always an array
|
// Ensure value is always an array
|
||||||
const selectedValues = Array.isArray(value) ? value : [];
|
const selectedValues = Array.isArray(value) ? value : [];
|
||||||
@@ -491,7 +497,14 @@ function MultiSelect({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Options */}
|
{/* Options */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ interface NPSProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
/** Whether the controls are disabled */
|
/** Whether the controls are disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function NPS({
|
function NPS({
|
||||||
@@ -47,6 +51,8 @@ function NPS({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<NPSProps>): React.JSX.Element {
|
}: Readonly<NPSProps>): React.JSX.Element {
|
||||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||||
|
|
||||||
@@ -162,7 +168,14 @@ function NPS({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* NPS Options */}
|
{/* NPS Options */}
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ interface OpenTextProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
rows?: number;
|
rows?: number;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
imageUrl?: string;
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function OpenText({
|
function OpenText({
|
||||||
@@ -42,6 +44,8 @@ function OpenText({
|
|||||||
dir = "auto",
|
dir = "auto",
|
||||||
rows = 3,
|
rows = 3,
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<OpenTextProps>): React.JSX.Element {
|
}: Readonly<OpenTextProps>): React.JSX.Element {
|
||||||
const [currentLength, setCurrentLength] = useState(value.length);
|
const [currentLength, setCurrentLength] = useState(value.length);
|
||||||
|
|
||||||
@@ -64,7 +68,14 @@ function OpenText({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Input or Textarea */}
|
{/* Input or Textarea */}
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ interface PictureSelectProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
/** Whether the options are disabled */
|
/** Whether the options are disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function PictureSelect({
|
function PictureSelect({
|
||||||
@@ -58,6 +62,8 @@ function PictureSelect({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<PictureSelectProps>): React.JSX.Element {
|
}: Readonly<PictureSelectProps>): React.JSX.Element {
|
||||||
// Ensure value is always the correct type
|
// Ensure value is always the correct type
|
||||||
let selectedValues: string[] | string | undefined;
|
let selectedValues: string[] | string | undefined;
|
||||||
@@ -86,7 +92,14 @@ function PictureSelect({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Picture Grid - 2 columns */}
|
{/* Picture Grid - 2 columns */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ interface RankingProps {
|
|||||||
dir?: TextDirection;
|
dir?: TextDirection;
|
||||||
/** Whether the controls are disabled */
|
/** Whether the controls are disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RankingItemProps {
|
interface RankingItemProps {
|
||||||
@@ -74,7 +78,14 @@ function getBottomButtonRadiusClass(isLast: boolean, dir?: TextDirection): strin
|
|||||||
return "rounded-br-md";
|
return "rounded-br-md";
|
||||||
}
|
}
|
||||||
|
|
||||||
function RankingItem({ item, rankedIds, onItemClick, onMove, disabled, dir }: Readonly<RankingItemProps>) {
|
function RankingItem({
|
||||||
|
item,
|
||||||
|
rankedIds,
|
||||||
|
onItemClick,
|
||||||
|
onMove,
|
||||||
|
disabled,
|
||||||
|
dir,
|
||||||
|
}: Readonly<RankingItemProps>): React.ReactNode {
|
||||||
const isRanked = rankedIds.includes(item.id);
|
const isRanked = rankedIds.includes(item.id);
|
||||||
const rankIndex = rankedIds.indexOf(item.id);
|
const rankIndex = rankedIds.indexOf(item.id);
|
||||||
const isFirst = isRanked && rankIndex === 0;
|
const isFirst = isRanked && rankIndex === 0;
|
||||||
@@ -183,6 +194,8 @@ function Ranking({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<RankingProps>): React.JSX.Element {
|
}: Readonly<RankingProps>): React.JSX.Element {
|
||||||
// Ensure value is always an array
|
// Ensure value is always an array
|
||||||
const rankedIds = React.useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
const rankedIds = React.useMemo(() => (Array.isArray(value) ? value : []), [value]);
|
||||||
@@ -232,7 +245,14 @@ function Ranking({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Ranking Options */}
|
{/* Ranking Options */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
|||||||
@@ -143,6 +143,10 @@ interface RatingProps {
|
|||||||
dir?: "ltr" | "rtl" | "auto";
|
dir?: "ltr" | "rtl" | "auto";
|
||||||
/** Whether the controls are disabled */
|
/** Whether the controls are disabled */
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Rating({
|
function Rating({
|
||||||
@@ -161,6 +165,8 @@ function Rating({
|
|||||||
errorMessage,
|
errorMessage,
|
||||||
dir = "auto",
|
dir = "auto",
|
||||||
disabled = false,
|
disabled = false,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<RatingProps>): React.JSX.Element {
|
}: Readonly<RatingProps>): React.JSX.Element {
|
||||||
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
const [hoveredValue, setHoveredValue] = React.useState<number | null>(null);
|
||||||
|
|
||||||
@@ -399,7 +405,14 @@ function Rating({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Rating Options */}
|
{/* Rating Options */}
|
||||||
<div className="relative space-y-2">
|
<div className="relative space-y-2">
|
||||||
|
|||||||
@@ -61,6 +61,10 @@ interface SingleSelectProps {
|
|||||||
otherValue?: string;
|
otherValue?: string;
|
||||||
/** Callback when the 'other' input value changes */
|
/** Callback when the 'other' input value changes */
|
||||||
onOtherValueChange?: (value: string) => void;
|
onOtherValueChange?: (value: string) => void;
|
||||||
|
/** Image URL to display above the headline */
|
||||||
|
imageUrl?: string;
|
||||||
|
/** Video URL to display above the headline */
|
||||||
|
videoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SingleSelect({
|
function SingleSelect({
|
||||||
@@ -82,6 +86,8 @@ function SingleSelect({
|
|||||||
otherOptionPlaceholder = "Please specify",
|
otherOptionPlaceholder = "Please specify",
|
||||||
otherValue = "",
|
otherValue = "",
|
||||||
onOtherValueChange,
|
onOtherValueChange,
|
||||||
|
imageUrl,
|
||||||
|
videoUrl,
|
||||||
}: Readonly<SingleSelectProps>): React.JSX.Element {
|
}: Readonly<SingleSelectProps>): React.JSX.Element {
|
||||||
// Ensure value is always a string or undefined
|
// Ensure value is always a string or undefined
|
||||||
const selectedValue = value ?? undefined;
|
const selectedValue = value ?? undefined;
|
||||||
@@ -131,7 +137,14 @@ function SingleSelect({
|
|||||||
return (
|
return (
|
||||||
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
<div className="w-full space-y-4" id={elementId} dir={dir}>
|
||||||
{/* Headline */}
|
{/* Headline */}
|
||||||
<ElementHeader headline={headline} description={description} required={required} htmlFor={inputId} />
|
<ElementHeader
|
||||||
|
headline={headline}
|
||||||
|
description={description}
|
||||||
|
required={required}
|
||||||
|
htmlFor={inputId}
|
||||||
|
imageUrl={imageUrl}
|
||||||
|
videoUrl={videoUrl}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* Options */}
|
{/* Options */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import * as React from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-button text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ function Calendar({
|
|||||||
formatters,
|
formatters,
|
||||||
components,
|
components,
|
||||||
locale,
|
locale,
|
||||||
|
startMonth,
|
||||||
|
endMonth,
|
||||||
...props
|
...props
|
||||||
}: Readonly<
|
}: Readonly<
|
||||||
React.ComponentProps<typeof DayPicker> & {
|
React.ComponentProps<typeof DayPicker> & {
|
||||||
@@ -78,6 +80,22 @@ function Calendar({
|
|||||||
return locale;
|
return locale;
|
||||||
}, [locale]);
|
}, [locale]);
|
||||||
|
|
||||||
|
const resolvedStartMonth = React.useMemo(() => {
|
||||||
|
if (startMonth) return startMonth;
|
||||||
|
if (captionLayout === "dropdown") {
|
||||||
|
return new Date(new Date().getFullYear() - 100, 0);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [startMonth, captionLayout]);
|
||||||
|
|
||||||
|
const resolvedEndMonth = React.useMemo(() => {
|
||||||
|
if (endMonth) return endMonth;
|
||||||
|
if (captionLayout === "dropdown") {
|
||||||
|
return new Date(new Date().getFullYear() + 100, 11);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}, [endMonth, captionLayout]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DayPicker
|
<DayPicker
|
||||||
showOutsideDays={showOutsideDays}
|
showOutsideDays={showOutsideDays}
|
||||||
@@ -89,6 +107,8 @@ function Calendar({
|
|||||||
)}
|
)}
|
||||||
captionLayout={captionLayout}
|
captionLayout={captionLayout}
|
||||||
locale={resolvedLocale}
|
locale={resolvedLocale}
|
||||||
|
startMonth={resolvedStartMonth}
|
||||||
|
endMonth={resolvedEndMonth}
|
||||||
formatters={{
|
formatters={{
|
||||||
formatMonthDropdown: (date) => {
|
formatMonthDropdown: (date) => {
|
||||||
if (resolvedLocale) {
|
if (resolvedLocale) {
|
||||||
@@ -156,7 +176,7 @@ function Calendar({
|
|||||||
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
range_middle: cn("rounded-none", defaultClassNames.range_middle),
|
||||||
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
|
||||||
today: cn(
|
today: cn(
|
||||||
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
|
"bg-accent text-brand-foreground rounded-md data-[selected=true]:rounded-none bg-brand opacity-50",
|
||||||
defaultClassNames.today
|
defaultClassNames.today
|
||||||
),
|
),
|
||||||
outside: cn(
|
outside: cn(
|
||||||
|
|||||||
@@ -3,30 +3,18 @@ import * as React from "react";
|
|||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video";
|
import { checkForLoomUrl, checkForVimeoUrl, checkForYoutubeUrl, convertToEmbedUrl } from "@/lib/video";
|
||||||
|
|
||||||
// Function to add extra params to videoUrls in order to reduce video controls
|
//Function to add extra params to videoUrls in order to reduce video controls
|
||||||
const getVideoUrlWithParams = (videoUrl: string): string | undefined => {
|
const getVideoUrlWithParams = (videoUrl: string): string => {
|
||||||
// First convert to embed URL
|
|
||||||
const embedUrl = convertToEmbedUrl(videoUrl);
|
|
||||||
if (!embedUrl) return undefined;
|
|
||||||
|
|
||||||
const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
|
const isYoutubeVideo = checkForYoutubeUrl(videoUrl);
|
||||||
const isVimeoUrl = checkForVimeoUrl(videoUrl);
|
const isVimeoUrl = checkForVimeoUrl(videoUrl);
|
||||||
const isLoomUrl = checkForLoomUrl(videoUrl);
|
const isLoomUrl = checkForLoomUrl(videoUrl);
|
||||||
|
if (isYoutubeVideo) return videoUrl.concat("?controls=0");
|
||||||
if (isYoutubeVideo) {
|
else if (isVimeoUrl)
|
||||||
// For YouTube, add parameters to embed URL
|
return videoUrl.concat(
|
||||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
"?title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false"
|
||||||
return `${embedUrl}${separator}controls=0`;
|
);
|
||||||
} else if (isVimeoUrl) {
|
else if (isLoomUrl) return videoUrl.concat("?hide_share=true&hideEmbedTopBar=true&hide_title=true");
|
||||||
// For Vimeo, add parameters to embed URL
|
return videoUrl;
|
||||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
|
||||||
return `${embedUrl}${separator}title=false&transcript=false&speed=false&quality_selector=false&progress_bar=false&pip=false&fullscreen=false&cc=false&chromecast=false`;
|
|
||||||
} else if (isLoomUrl) {
|
|
||||||
// For Loom, add parameters to embed URL
|
|
||||||
const separator = embedUrl.includes("?") ? "&" : "?";
|
|
||||||
return `${embedUrl}${separator}hide_share=true&hideEmbedTopBar=true&hide_title=true`;
|
|
||||||
}
|
|
||||||
return embedUrl;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ElementMediaProps {
|
interface ElementMediaProps {
|
||||||
@@ -35,16 +23,12 @@ interface ElementMediaProps {
|
|||||||
altText?: string;
|
altText?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ElementMedia({
|
function ElementMedia({ imgUrl, videoUrl, altText = "Image" }: Readonly<ElementMediaProps>): React.ReactNode {
|
||||||
imgUrl,
|
|
||||||
videoUrl,
|
|
||||||
altText = "Image",
|
|
||||||
}: Readonly<ElementMediaProps>): React.JSX.Element {
|
|
||||||
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
|
const videoUrlWithParams = videoUrl ? getVideoUrlWithParams(videoUrl) : undefined;
|
||||||
const [isLoading, setIsLoading] = React.useState(true);
|
const [isLoading, setIsLoading] = React.useState(true);
|
||||||
|
|
||||||
if (!imgUrl && !videoUrl) {
|
if (!imgUrl && !videoUrl) {
|
||||||
return <></>;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
|
|||||||
|
|
||||||
if (vimeoUrl.protocol !== "https:") return false;
|
if (vimeoUrl.protocol !== "https:") return false;
|
||||||
|
|
||||||
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
|
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
|
||||||
const hostname = vimeoUrl.hostname;
|
const hostname = vimeoUrl.hostname;
|
||||||
|
|
||||||
return vimeoDomains.includes(hostname);
|
return vimeoDomains.includes(hostname);
|
||||||
@@ -77,14 +77,14 @@ const extractYoutubeId = (url: string): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const extractVimeoId = (url: string): string | null => {
|
const extractVimeoId = (url: string): string | null => {
|
||||||
const regExp = /vimeo\.com\/(?<videoId>\d+)/;
|
const regExp = /vimeo\.com\/(?:video\/)?(?<videoId>\d+)/;
|
||||||
const match = regExp.exec(url);
|
const match = regExp.exec(url);
|
||||||
|
|
||||||
return match?.groups?.videoId ?? null;
|
return match?.groups?.videoId ?? null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const extractLoomId = (url: string): string | null => {
|
const extractLoomId = (url: string): string | null => {
|
||||||
const regExp = /loom\.com\/share\/(?<videoId>[a-zA-Z0-9]+)/;
|
const regExp = /loom\.com\/(?:share|embed)\/(?<videoId>[a-zA-Z0-9]+)/;
|
||||||
const match = regExp.exec(url);
|
const match = regExp.exec(url);
|
||||||
|
|
||||||
return match?.groups?.videoId ?? null;
|
return match?.groups?.videoId ?? null;
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export function AddressElement({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required={element.required}
|
required={element.required}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export function ConsentElement({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required={element.required}
|
required={element.required}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ export function ContactInfoElement({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required={element.required}
|
required={element.required}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ export function CTAElement({
|
|||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
// CTA cannot be required
|
// CTA cannot be required
|
||||||
required={false}
|
required={false}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -56,13 +56,12 @@ export function DateElement({
|
|||||||
setTtc(updatedTtcObj);
|
setTtc(updatedTtcObj);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use default date range (100 years ago to year 3000)
|
|
||||||
const getMinDate = (): string | undefined => {
|
const getMinDate = (): string | undefined => {
|
||||||
return new Date(new Date().getFullYear() - 100, 0, 1).toISOString().split("T")[0];
|
return new Date(new Date().getFullYear() - 100, 0, 1).toISOString().split("T")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
const getMaxDate = (): string | undefined => {
|
const getMaxDate = (): string | undefined => {
|
||||||
return "3000-12-31";
|
return new Date(new Date().getFullYear() + 100, 0, 1).toISOString().split("T")[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -79,6 +78,8 @@ export function DateElement({
|
|||||||
required={element.required}
|
required={element.required}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
locale={languageCode}
|
locale={languageCode}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -134,6 +134,8 @@ export function MatrixElement({
|
|||||||
value={convertValueToIds(value)}
|
value={convertValueToIds(value)}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required={element.required}
|
required={element.required}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -258,6 +258,8 @@ export function MultipleChoiceMultiElement({
|
|||||||
otherValue={otherValue}
|
otherValue={otherValue}
|
||||||
onOtherValueChange={handleOtherValueChange}
|
onOtherValueChange={handleOtherValueChange}
|
||||||
exclusiveOptionIds={noneOption ? [noneOption.id] : []}
|
exclusiveOptionIds={noneOption ? [noneOption.id] : []}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -175,6 +175,8 @@ export function MultipleChoiceSingleElement({
|
|||||||
}
|
}
|
||||||
otherValue={otherValue}
|
otherValue={otherValue}
|
||||||
onOtherValueChange={handleOtherValueChange}
|
onOtherValueChange={handleOtherValueChange}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export function NPSElement({
|
|||||||
colorCoding={element.isColorCodingEnabled}
|
colorCoding={element.isColorCodingEnabled}
|
||||||
required={element.required}
|
required={element.required}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -129,6 +129,8 @@ export function OpenTextElement({
|
|||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
rows={3}
|
rows={3}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -98,6 +98,8 @@ export function PictureSelectionElement({
|
|||||||
required={element.required}
|
required={element.required}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -137,6 +137,8 @@ export function RankingElement({
|
|||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
required={element.required}
|
required={element.required}
|
||||||
errorMessage={errorMessage}
|
errorMessage={errorMessage}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ export function RatingElement({
|
|||||||
colorCoding={element.isColorCodingEnabled}
|
colorCoding={element.isColorCodingEnabled}
|
||||||
required={element.required}
|
required={element.required}
|
||||||
dir={dir}
|
dir={dir}
|
||||||
|
imageUrl={element.imageUrl}
|
||||||
|
videoUrl={element.videoUrl}
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -173,7 +173,8 @@ export function BlockConditional({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For other element types, check if required fields are empty
|
// For other element types, check if required fields are empty
|
||||||
if (element.required && isEmptyResponse(response)) {
|
// CTA elements should not block navigation even if marked required (as they are informational)
|
||||||
|
if (element.type !== TSurveyElementTypeEnum.CTA && element.required && isEmptyResponse(response)) {
|
||||||
form.requestSubmit();
|
form.requestSubmit();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -295,7 +296,7 @@ export function BlockConditional({
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex w-full flex-row-reverse justify-between",
|
"flex w-full flex-row-reverse justify-between",
|
||||||
fullSizeCards ? "sticky bottom-0 bg-white" : ""
|
fullSizeCards ? "bg-survey-bg sticky bottom-0" : ""
|
||||||
)}>
|
)}>
|
||||||
<div>
|
<div>
|
||||||
<SubmitButton
|
<SubmitButton
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export function CalEmbed({ element, onSuccessfulBooking }: CalEmbedProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative mt-4 overflow-auto">
|
<div className="relative mt-4 overflow-auto">
|
||||||
<div id="cal-embed" className={cn("border-border rounded-lg border")} />
|
<div id="cal-embed" className={cn("border-border rounded-input border")} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,12 @@ export function ErrorComponent({ errorType }: ErrorComponentProps) {
|
|||||||
const error = errorData[errorType];
|
const error = errorData[errorType];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center bg-white p-8 text-center" role="alert" aria-live="assertive">
|
<div
|
||||||
<span className="mb-1.5 text-base leading-6 font-bold text-slate-900">{error.title}</span>
|
className="bg-survey-bg text-heading flex flex-col items-center p-8 text-center"
|
||||||
<p className="max-w-lg text-sm leading-6 font-normal text-slate-600">{error.message}</p>
|
role="alert"
|
||||||
|
aria-live="assertive">
|
||||||
|
<span className="mb-1.5 text-base leading-6 font-bold">{error.title}</span>
|
||||||
|
<p className="max-w-lg text-sm leading-6 font-normal">{error.message}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,9 @@ export function ResponseErrorComponent({
|
|||||||
}: ResponseErrorComponentProps) {
|
}: ResponseErrorComponentProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col bg-white p-4">
|
<div className="bg-survey-bg text-heading flex flex-col p-4">
|
||||||
<span className="mb-1.5 text-base leading-6 font-bold text-slate-900">
|
<span className="mb-1.5 text-base leading-6 font-bold">{t("common.your_feedback_is_stuck")}</span>
|
||||||
{t("common.your_feedback_is_stuck")}
|
<p className="max-w-md text-sm leading-6 font-normal">
|
||||||
</span>
|
|
||||||
<p className="max-w-md text-sm leading-6 font-normal text-slate-600">
|
|
||||||
{t("common.the_servers_cannot_be_reached_at_the_moment")}
|
{t("common.the_servers_cannot_be_reached_at_the_moment")}
|
||||||
<br />
|
<br />
|
||||||
{t("common.please_retry_now_or_try_again_later")}
|
{t("common.please_retry_now_or_try_again_later")}
|
||||||
|
|||||||
@@ -743,7 +743,7 @@ export function Survey({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{localSurvey.type !== "link" ? (
|
{localSurvey.type !== "link" ? (
|
||||||
<div className="flex h-6 justify-end bg-white pt-2 pr-2">
|
<div className="bg-survey-bg flex h-6 justify-end pt-2 pr-2">
|
||||||
<SurveyCloseButton onClose={onClose} />
|
<SurveyCloseButton onClose={onClose} />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ export const addCustomThemeToDom = ({ styling }: { styling: TProjectStyling | TS
|
|||||||
appendCssVariable("survey-background-color", styling.cardBackgroundColor?.light);
|
appendCssVariable("survey-background-color", styling.cardBackgroundColor?.light);
|
||||||
appendCssVariable("survey-border-color", styling.cardBorderColor?.light);
|
appendCssVariable("survey-border-color", styling.cardBorderColor?.light);
|
||||||
appendCssVariable("border-radius", `${Number(roundness).toString()}px`);
|
appendCssVariable("border-radius", `${Number(roundness).toString()}px`);
|
||||||
|
appendCssVariable("input-border-radius", `${Number(roundness).toString()}px`);
|
||||||
|
appendCssVariable("option-border-radius", `${Number(roundness).toString()}px`);
|
||||||
|
appendCssVariable("button-border-radius", `${Number(roundness).toString()}px`);
|
||||||
appendCssVariable("input-background-color", styling.inputColor?.light);
|
appendCssVariable("input-background-color", styling.inputColor?.light);
|
||||||
appendCssVariable("input-bg-color", styling.inputColor?.light);
|
appendCssVariable("input-bg-color", styling.inputColor?.light);
|
||||||
appendCssVariable("option-bg-color", styling.inputColor?.light);
|
appendCssVariable("option-bg-color", styling.inputColor?.light);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const checkForVimeoUrl = (url: string): boolean => {
|
|||||||
|
|
||||||
if (vimeoUrl.protocol !== "https:") return false;
|
if (vimeoUrl.protocol !== "https:") return false;
|
||||||
|
|
||||||
const vimeoDomains = ["www.vimeo.com", "vimeo.com"];
|
const vimeoDomains = ["www.vimeo.com", "vimeo.com", "player.vimeo.com"];
|
||||||
const hostname = vimeoUrl.hostname;
|
const hostname = vimeoUrl.hostname;
|
||||||
|
|
||||||
return vimeoDomains.includes(hostname);
|
return vimeoDomains.includes(hostname);
|
||||||
@@ -77,7 +77,7 @@ export const extractYoutubeId = (url: string): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const extractVimeoId = (url: string): string | null => {
|
const extractVimeoId = (url: string): string | null => {
|
||||||
const regExp = /vimeo\.com\/(\d+)/;
|
const regExp = /vimeo\.com\/(?:video\/)?(\d+)/;
|
||||||
const match = url.match(regExp);
|
const match = url.match(regExp);
|
||||||
|
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
@@ -87,7 +87,7 @@ const extractVimeoId = (url: string): string | null => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const extractLoomId = (url: string): string | null => {
|
const extractLoomId = (url: string): string | null => {
|
||||||
const regExp = /loom\.com\/share\/([a-zA-Z0-9]+)/;
|
const regExp = /loom\.com\/(?:share|embed)\/([a-zA-Z0-9]+)/;
|
||||||
const match = url.match(regExp);
|
const match = url.match(regExp);
|
||||||
|
|
||||||
if (match && match[1]) {
|
if (match && match[1]) {
|
||||||
|
|||||||
36
pnpm-lock.yaml
generated
36
pnpm-lock.yaml
generated
@@ -423,6 +423,9 @@ importers:
|
|||||||
redis:
|
redis:
|
||||||
specifier: 4.7.0
|
specifier: 4.7.0
|
||||||
version: 4.7.0
|
version: 4.7.0
|
||||||
|
sanitize-html:
|
||||||
|
specifier: 2.17.0
|
||||||
|
version: 2.17.0
|
||||||
server-only:
|
server-only:
|
||||||
specifier: 0.0.1
|
specifier: 0.0.1
|
||||||
version: 0.0.1
|
version: 0.0.1
|
||||||
@@ -499,6 +502,9 @@ importers:
|
|||||||
'@types/qrcode':
|
'@types/qrcode':
|
||||||
specifier: 1.5.5
|
specifier: 1.5.5
|
||||||
version: 1.5.5
|
version: 1.5.5
|
||||||
|
'@types/sanitize-html':
|
||||||
|
specifier: 2.16.0
|
||||||
|
version: 2.16.0
|
||||||
'@types/testing-library__react':
|
'@types/testing-library__react':
|
||||||
specifier: 10.2.0
|
specifier: 10.2.0
|
||||||
version: 10.2.0(@testing-library/dom@8.20.1)(@types/react-dom@19.2.1(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
version: 10.2.0(@testing-library/dom@8.20.1)(@types/react-dom@19.2.1(@types/react@19.2.7))(@types/react@19.2.7)(react-dom@19.2.1(react@19.2.1))(react@19.2.1)
|
||||||
@@ -4877,6 +4883,9 @@ packages:
|
|||||||
'@types/resolve@1.20.6':
|
'@types/resolve@1.20.6':
|
||||||
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
|
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
|
||||||
|
|
||||||
|
'@types/sanitize-html@2.16.0':
|
||||||
|
resolution: {integrity: sha512-l6rX1MUXje5ztPT0cAFtUayXF06DqPhRyfVXareEN5gGCFaP/iwsxIyKODr9XDhfxPpN6vXUFNfo5kZMXCxBtw==}
|
||||||
|
|
||||||
'@types/semver@7.7.1':
|
'@types/semver@7.7.1':
|
||||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||||
|
|
||||||
@@ -7411,6 +7420,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0:
|
||||||
|
resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1:
|
is-potential-custom-element-name@1.0.1:
|
||||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||||
|
|
||||||
@@ -8431,6 +8444,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
parse-srcset@1.0.2:
|
||||||
|
resolution: {integrity: sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==}
|
||||||
|
|
||||||
parse5@8.0.0:
|
parse5@8.0.0:
|
||||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||||
|
|
||||||
@@ -9195,6 +9211,9 @@ packages:
|
|||||||
safer-buffer@2.1.2:
|
safer-buffer@2.1.2:
|
||||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||||
|
|
||||||
|
sanitize-html@2.17.0:
|
||||||
|
resolution: {integrity: sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==}
|
||||||
|
|
||||||
satori@0.16.0:
|
satori@0.16.0:
|
||||||
resolution: {integrity: sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==}
|
resolution: {integrity: sha512-ZvHN3ygzZ8FuxjSNB+mKBiF/NIoqHzlBGbD0MJiT+MvSsFOvotnWOhdTjxKzhHRT2wPC1QbhLzx2q/Y83VhfYQ==}
|
||||||
engines: {node: '>=16'}
|
engines: {node: '>=16'}
|
||||||
@@ -15890,6 +15909,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/resolve@1.20.6': {}
|
'@types/resolve@1.20.6': {}
|
||||||
|
|
||||||
|
'@types/sanitize-html@2.16.0':
|
||||||
|
dependencies:
|
||||||
|
htmlparser2: 8.0.2
|
||||||
|
|
||||||
'@types/semver@7.7.1': {}
|
'@types/semver@7.7.1': {}
|
||||||
|
|
||||||
'@types/shimmer@1.2.0': {}
|
'@types/shimmer@1.2.0': {}
|
||||||
@@ -18923,6 +18946,8 @@ snapshots:
|
|||||||
|
|
||||||
is-plain-obj@4.1.0: {}
|
is-plain-obj@4.1.0: {}
|
||||||
|
|
||||||
|
is-plain-object@5.0.0: {}
|
||||||
|
|
||||||
is-potential-custom-element-name@1.0.1: {}
|
is-potential-custom-element-name@1.0.1: {}
|
||||||
|
|
||||||
is-property@1.0.2: {}
|
is-property@1.0.2: {}
|
||||||
@@ -19971,6 +19996,8 @@ snapshots:
|
|||||||
json-parse-even-better-errors: 2.3.1
|
json-parse-even-better-errors: 2.3.1
|
||||||
lines-and-columns: 1.2.4
|
lines-and-columns: 1.2.4
|
||||||
|
|
||||||
|
parse-srcset@1.0.2: {}
|
||||||
|
|
||||||
parse5@8.0.0:
|
parse5@8.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
entities: 6.0.1
|
entities: 6.0.1
|
||||||
@@ -20790,6 +20817,15 @@ snapshots:
|
|||||||
|
|
||||||
safer-buffer@2.1.2: {}
|
safer-buffer@2.1.2: {}
|
||||||
|
|
||||||
|
sanitize-html@2.17.0:
|
||||||
|
dependencies:
|
||||||
|
deepmerge: 4.3.1
|
||||||
|
escape-string-regexp: 4.0.0
|
||||||
|
htmlparser2: 8.0.2
|
||||||
|
is-plain-object: 5.0.0
|
||||||
|
parse-srcset: 1.0.2
|
||||||
|
postcss: 8.5.3
|
||||||
|
|
||||||
satori@0.16.0:
|
satori@0.16.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@shuding/opentype.js': 1.4.0-beta.0
|
'@shuding/opentype.js': 1.4.0-beta.0
|
||||||
|
|||||||
Reference in New Issue
Block a user