diff --git a/echo/frontend/src/components/common/FeedbackPortalModal.tsx b/echo/frontend/src/components/common/FeedbackPortalModal.tsx new file mode 100644 index 00000000..48e42c59 --- /dev/null +++ b/echo/frontend/src/components/common/FeedbackPortalModal.tsx @@ -0,0 +1,111 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { + Anchor, + Button, + Divider, + Group, + Modal, + rem, + Stack, + Text, +} from "@mantine/core"; +import { getProductFeedbackUrl } from "@/config"; +import { QRCode } from "./QRCode"; + +interface FeedbackPortalModalProps { + opened: boolean; + onClose: () => void; + locale?: string; +} + +export const FeedbackPortalModal = ({ + opened, + onClose, + locale, +}: FeedbackPortalModalProps) => { + const feedbackUrl = getProductFeedbackUrl(locale); + + const actionButtonStyles = { + root: { + minHeight: rem(40), + paddingBottom: rem(10), + paddingLeft: rem(20), + paddingRight: rem(20), + paddingTop: rem(10), + }, + } as const; + + return ( + + + + + + We'd love to hear from you. Whether you have an idea for something + new, you've hit a bug, spotted a translation that feels off, or + just want to share how things have been going. + + + + + To help us act on it, try to include where it happened and what + you were trying to do. For bugs, tell us what went wrong. For + ideas, tell us what need it would solve for you. + + + + + Just talk or type naturally. Your input goes directly to our + product team and genuinely helps us make dembrane better. We read + everything. + + + + + + + + + Scan or click to open the feedback portal + + + + Or prefer to chat directly? + + + Book a call with us + + + + + + + + + + + + + ); +}; diff --git a/echo/frontend/src/components/common/QRCode.tsx b/echo/frontend/src/components/common/QRCode.tsx index cc165de4..61f597a2 100644 --- a/echo/frontend/src/components/common/QRCode.tsx +++ b/echo/frontend/src/components/common/QRCode.tsx @@ -1,16 +1,32 @@ +import { rem } from "@mantine/core"; +import { IconExternalLink } from "@tabler/icons-react"; +import { type CSSProperties, type Ref, useState } from "react"; import { QRCode as Q } from "react-qrcode-logo"; import { CURRENT_BRAND } from "./Logo"; -/** - * QRCode component - * Try to wrap this component in a div with a fixed width and height - */ -export const QRCode = (props: { value: string; ref?: any }) => { - return ( +interface QRCodeProps { + value: string; + href?: string; + ref?: Ref; + className?: string; + style?: CSSProperties; + "data-testid"?: string; +} + +export const QRCode = ({ + value, + href, + ref, + className, + style, + "data-testid": dataTestId, +}: QRCodeProps) => { + const [hovered, setHovered] = useState(false); + + const qrElement = ( { }} /> ); + + if (!href) { + return ( +
+ {qrElement} +
+ ); + } + + return ( + } + href={href} + target="_blank" + rel="noopener noreferrer" + className={`relative block cursor-pointer overflow-hidden rounded-lg bg-white transition-all ${className ?? ""}`} + style={style} + data-testid={dataTestId} + onMouseEnter={() => setHovered(true)} + onMouseLeave={() => setHovered(false)} + > + {qrElement} +
+ +
+
+ ); }; diff --git a/echo/frontend/src/components/layout/Header.tsx b/echo/frontend/src/components/layout/Header.tsx index 7769b8b7..2518cea2 100644 --- a/echo/frontend/src/components/layout/Header.tsx +++ b/echo/frontend/src/components/layout/Header.tsx @@ -2,15 +2,11 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { ActionIcon, - Anchor, Badge, Box, - Button, Group, Menu, - Modal, Paper, - Stack, Text, } from "@mantine/core"; import * as Sentry from "@sentry/react"; @@ -35,7 +31,6 @@ import { COMMUNITY_SLACK_URL, DIRECTUS_PUBLIC_URL, ENABLE_ANNOUNCEMENTS, - getProductFeedbackUrl, } from "@/config"; import { useI18nNavigate } from "@/hooks/useI18nNavigate"; import { useWhitelabelLogo } from "@/hooks/useWhitelabelLogo"; @@ -45,6 +40,7 @@ import { testId } from "@/lib/testUtils"; import { AnnouncementIcon } from "../announcement/AnnouncementIcon"; import { Announcements } from "../announcement/Announcements"; import { TopAnnouncementBar } from "../announcement/TopAnnouncementBar"; +import { FeedbackPortalModal } from "../common/FeedbackPortalModal"; import { Logo } from "../common/Logo"; import { UserAvatar } from "../common/UserAvatar"; import { LanguagePicker } from "../language/LanguagePicker"; @@ -286,97 +282,14 @@ const HeaderView = ({ isAuthenticated, loading }: HeaderViewProps) => { - setFeedbackFallbackOpen(false)} - title={t`Report an issue`} - centered - > - - - - The built-in issue reporter could not be loaded. You can still let - us know what went wrong through our feedback portal. It helps us - fix things faster than not submitting a report. - - - - - - - - - - setFeedbackPortalOpen(false)} - title={t`Feedback portal`} - centered - > - - - - We'd love to hear from you. Whether you have an idea for something - new, you've hit a bug, spotted a translation that feels off, or - just want to share how things have been going. - - - - - To help us act on it, try to include where it happened and what - you were trying to do. For bugs, tell us what went wrong. For - ideas, tell us what need it would solve for you. - - - - - Just talk or type naturally. Your input goes directly to our - product team and genuinely helps us make dembrane better. We read - everything. - - - - - Prefer to chat directly?{" "} - - Book a call with me - - - - - - - - - + { + setFeedbackFallbackOpen(false); + setFeedbackPortalOpen(false); + }} + locale={language} + /> ); }; diff --git a/echo/frontend/src/components/project/ProjectQRCode.tsx b/echo/frontend/src/components/project/ProjectQRCode.tsx index abb5bf5b..24cf92b4 100644 --- a/echo/frontend/src/components/project/ProjectQRCode.tsx +++ b/echo/frontend/src/components/project/ProjectQRCode.tsx @@ -1,7 +1,6 @@ import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { - Box, Button, CopyButton, Group, @@ -14,10 +13,9 @@ import { IconCheck, IconCopy, IconDownload, - IconExternalLink, IconPresentation, } from "@tabler/icons-react"; -import { useMemo, useRef, useState } from "react"; +import { useMemo, useRef } from "react"; import { PARTICIPANT_BASE_URL } from "@/config"; import { useAppPreferences } from "@/hooks/useAppPreferences"; import { testId } from "@/lib/testUtils"; @@ -81,7 +79,6 @@ export const useProjectSharingLink = (project?: Project) => { export const ProjectQRCode = ({ project }: ProjectQRCodeProps) => { const link = useProjectSharingLink(project); - const [qrHovered, setQrHovered] = useState(false); const qrRef = useRef(null); const handleOpenHostGuide = () => { @@ -133,32 +130,13 @@ export const ProjectQRCode = ({ project }: ProjectQRCodeProps) => { > {project?.is_conversation_allowed ? ( - {/* Interactive QR Code */} - setQrHovered(true)} - onMouseLeave={() => setQrHovered(false)} - onClick={() => window.open(link, "_blank")} + className="h-auto w-full min-w-[80px] max-w-[128px]" {...testId("project-qr-code")} - > - - {/* Hover overlay */} -
- -
-
+ />
{showQuickStart && ( + + - -
- Share your voice by scanning the QR code below. -
- -
- -
+ style={{ height: 200, width: 200 }} + {...testId("report-contribute-qr")} + /> ); diff --git a/echo/frontend/src/components/report/ScheduleDateTimePicker.tsx b/echo/frontend/src/components/report/ScheduleDateTimePicker.tsx new file mode 100644 index 00000000..fb5f82e4 --- /dev/null +++ b/echo/frontend/src/components/report/ScheduleDateTimePicker.tsx @@ -0,0 +1,118 @@ +import { t } from "@lingui/core/macro"; +import { Trans } from "@lingui/react/macro"; +import { Group, Stack, Text } from "@mantine/core"; +import { DatePickerInput, TimeInput } from "@mantine/dates"; +import { useRef } from "react"; + +/** Returns a Date 10 minutes from now (rounded up to next 5-min mark). */ +function getMinScheduleDate(): Date { + const d = new Date(Date.now() + 10 * 60_000); + const mins = d.getMinutes(); + const remainder = mins % 5; + if (remainder !== 0) d.setMinutes(mins + (5 - remainder), 0, 0); + return d; +} + +/** 30 days from now. */ +function getMaxScheduleDate(): Date { + return new Date(Date.now() + 30 * 24 * 60 * 60_000); +} + +/** Combine a date and a time string (HH:mm) into a single Date. */ +function combineDateTime(date: Date | null, time: string): Date | null { + if (!date || !time) return null; + const [hours, minutes] = time.split(":").map(Number); + if (Number.isNaN(hours) || Number.isNaN(minutes)) return null; + const combined = new Date(date); + combined.setHours(hours, minutes, 0, 0); + return combined; +} + +/** Check if a date is at least 10 minutes in the future. */ +function isDateFarEnough(date: Date | null): boolean { + if (!date) return false; + return date.getTime() > Date.now() + 10 * 60_000; +} + +/** Format a Date to HH:mm string. */ +function formatTime(date: Date | null): string { + if (!date) return ""; + return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; +} + +interface ScheduleDateTimePickerProps { + value: Date | null; + onChange: (value: Date | null) => void; + label?: string; +} + +export function ScheduleDateTimePicker({ + value, + onChange, + label, +}: ScheduleDateTimePickerProps) { + const timeRef = useRef(null); + const minDate = getMinScheduleDate(); + + const dateValue = value + ? new Date(value.getFullYear(), value.getMonth(), value.getDate()) + : null; + const timeValue = value ? formatTime(value) : ""; + + const handleDateChange = (date: Date | null) => { + if (!date) { + onChange(null); + return; + } + // If time is already set, combine; otherwise default to the min schedule time + const time = timeValue || formatTime(minDate); + onChange(combineDateTime(date, time)); + }; + + const handleTimeChange = (event: React.ChangeEvent) => { + const time = event.currentTarget.value; + if (!dateValue) return; + onChange(combineDateTime(dateValue, time)); + }; + + const tooSoon = value && !isDateFarEnough(value); + + return ( + + {label && ( + + {label} + + )} + + + + + {tooSoon && ( + + Must be at least 10 minutes in the future + + )} + + ); +} + +export { isDateFarEnough }; diff --git a/echo/frontend/src/components/report/UpdateReportModalButton.tsx b/echo/frontend/src/components/report/UpdateReportModalButton.tsx index 4ae7bf30..630586aa 100644 --- a/echo/frontend/src/components/report/UpdateReportModalButton.tsx +++ b/echo/frontend/src/components/report/UpdateReportModalButton.tsx @@ -7,35 +7,30 @@ import { Button, Divider, Group, + Indicator, Modal, NativeSelect, Stack, Text, Tooltip, } from "@mantine/core"; -import { DateTimePicker } from "@mantine/dates"; -import { useDisclosure } from "@mantine/hooks"; import { - IconArrowLeft, - IconClock, - IconExternalLink, - IconPencil, -} from "@tabler/icons-react"; + isDateFarEnough, + ScheduleDateTimePicker, +} from "./ScheduleDateTimePicker"; +import { useDisclosure } from "@mantine/hooks"; +import { IconArrowLeft, IconClock, IconPencil } from "@tabler/icons-react"; import { AxiosError } from "axios"; import { useState } from "react"; import { useParams } from "react-router"; -import { getProductFeedbackUrl } from "@/config"; +import { FeedbackPortalModal } from "@/components/common/FeedbackPortalModal"; import focusOptionsData from "@/data/reportFocusOptions.json"; import { useLanguage } from "@/hooks/useLanguage"; import { analytics } from "@/lib/analytics"; import { AnalyticsEvents as events } from "@/lib/analyticsEvents"; import { testId } from "@/lib/testUtils"; import { languageOptionsByIso639_1 } from "../language/LanguagePicker"; -import { - useCreateProjectReportMutation, - useDoesProjectReportNeedUpdate, - useProjectReport, -} from "./hooks"; +import { useCreateProjectReportMutation, useProjectReport } from "./hooks"; import { ReportFocusSelector } from "./ReportFocusSelector"; function getLanguageLabel(iso: string): string { @@ -62,28 +57,16 @@ function getCustomFocusText(instructions: string): string { return remaining.replace(/\n{2,}/g, "\n").trim(); } -/** Returns a Date 10 minutes from now (rounded up to next 5-min mark). */ -function getMinScheduleDate(): Date { - const d = new Date(Date.now() + 10 * 60_000); - const mins = d.getMinutes(); - const remainder = mins % 5; - if (remainder !== 0) d.setMinutes(mins + (5 - remainder), 0, 0); - return d; -} - -/** 30 days from now. */ -function getMaxScheduleDate(): Date { - return new Date(Date.now() + 30 * 24 * 60 * 60_000); -} - // Feature flag: show "custom report structure" CTA until 2026-05-11 (8 weeks from 2026-03-16) const SHOW_STRUCTURE_CTA = Date.now() < new Date("2026-05-11T00:00:00Z").getTime(); export const UpdateReportModalButton = ({ reportId: currentReportId, + needsUpdate = false, }: { reportId: number; + needsUpdate?: boolean; }) => { const [opened, { open, close }] = useDisclosure(false); const { mutateAsync, isPending, error } = useCreateProjectReportMutation(); @@ -92,10 +75,6 @@ export const UpdateReportModalButton = ({ projectId ?? "", currentReportId, ); - const { data: doesReportNeedUpdate } = useDoesProjectReportNeedUpdate( - projectId ?? "", - currentReportId, - ); const { iso639_1, language: appLocale } = useLanguage(); const [language, setLanguage] = useState( @@ -106,6 +85,7 @@ export const UpdateReportModalButton = ({ ); const [showSchedule, setShowSchedule] = useState(false); const [scheduledDate, setScheduledDate] = useState(null); + const [feedbackOpen, setFeedbackOpen] = useState(false); if (!currentReport) { return null; @@ -149,37 +129,22 @@ export const UpdateReportModalButton = ({ <> - + + {showSchedule ? ( Schedule Report - ) : doesReportNeedUpdate ? ( - Update Report ) : ( New Report )} @@ -266,14 +229,10 @@ export const UpdateReportModalButton = ({ - @@ -286,7 +245,7 @@ export const UpdateReportModalButton = ({