diff --git a/src/platform/package.json b/src/platform/package.json index 7993750445..bb6ef54352 100644 --- a/src/platform/package.json +++ b/src/platform/package.json @@ -3,8 +3,10 @@ "version": "3.7.0", "private": true, "scripts": { + "clean:next": "node -e \"const fs=require('node:fs'); try { fs.rmSync('.next',{recursive:true,force:true,maxRetries:10,retryDelay:200}); } catch (error) { if (error && typeof error === 'object' && 'code' in error && (error.code === 'EPERM' || error.code === 'EBUSY')) { console.warn('[clean:next] Skipping .next cleanup because the directory is locked:', error.code); } else { throw error; } }\"", "dev": "next dev", "build": "next build", + "prebuild": "yarn clean:next", "postbuild": "node -e \"const fs=require('node:fs'); const path=require('node:path'); const projectRoot=process.cwd(); const standaloneRoot=path.join(projectRoot,'.next','standalone'); const publicSource=path.join(projectRoot,'public'); const publicTarget=path.join(standaloneRoot,'public'); const staticSource=path.join(projectRoot,'.next','static'); const staticTarget=path.join(standaloneRoot,'.next','static'); const copyDirectory=(source,target)=>{ if(!fs.existsSync(source)) return false; fs.mkdirSync(path.dirname(target),{recursive:true}); fs.rmSync(target,{recursive:true,force:true}); fs.cpSync(source,target,{recursive:true}); return true; }; if(!fs.existsSync(standaloneRoot)){ throw new Error(`Standalone output not found at ${standaloneRoot}. Run 'yarn build' first.`); } copyDirectory(publicSource, publicTarget); copyDirectory(staticSource, staticTarget);\"", "start": "node -e \"const { loadEnvConfig } = require('@next/env'); loadEnvConfig(process.cwd()); process.env.HOSTNAME='localhost'; require('./.next/standalone/server.js')\"", "lint": "next lint", diff --git a/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx b/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx index cc66cf8556..c6b675c812 100644 --- a/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx +++ b/src/platform/src/app/(dashboard)/system/feedback/[feedbackId]/page.tsx @@ -1,14 +1,17 @@ 'use client'; import React, { useEffect, useMemo, useState } from 'react'; +import Image from 'next/image'; import { useParams, useRouter } from 'next/navigation'; import useSWR, { useSWRConfig } from 'swr'; +import { FaRegStar, FaStar } from 'react-icons/fa'; +import { FiExternalLink } from 'react-icons/fi'; import { PermissionGuard } from '@/shared/components'; import { Button, Card, - PageHeading, LoadingState, + PageHeading, } from '@/shared/components/ui'; import { AqArrowLeft } from '@airqo/icons-react'; import { feedbackService } from '@/modules/feedback'; @@ -60,6 +63,41 @@ const formatDateTime = (value: string) => minute: '2-digit', }); +const formatKeyLabel = (key: string) => + key + .replace(/([a-z0-9])([A-Z])/g, '$1 $2') + .replace(/_/g, ' ') + .replace(/\b\w/g, character => character.toUpperCase()); + +const DetailPanel: React.FC<{ + label: string; + children: React.ReactNode; + valueClassName?: string; +}> = ({ label, children, valueClassName = '' }) => ( +
+

+ {label} +

+
+ {children} +
+
+); + +const SectionHeader: React.FC<{ + title: string; + description: string; + action?: React.ReactNode; +}> = ({ title, description, action }) => ( +
+
+

{title}

+

{description}

+
+ {action ?
{action}
: null} +
+); + const FeedbackDetailsContent: React.FC<{ feedbackId: string }> = ({ feedbackId, }) => { @@ -91,14 +129,65 @@ const FeedbackDetailsContent: React.FC<{ feedbackId: string }> = ({ return []; } - return [ - ['Page', feedback.metadata.page || '--'], - ['Browser', feedback.metadata.browser || '--'], - ['App version', feedback.metadata.appVersion || '--'], - ['Screen resolution', feedback.metadata.screenResolution || '--'], - ] as Array<[string, string]>; + return Object.entries(feedback.metadata) + .filter(([, value]) => { + if (value === null || value === undefined) { + return false; + } + + const normalizedValue = String(value).trim(); + return normalizedValue.length > 0; + }) + .map(([key, value]) => ({ + key, + label: formatKeyLabel(key), + value: String(value), + })) + .sort((leftEntry, rightEntry) => + leftEntry.label.localeCompare(rightEntry.label) + ); }, [feedback?.metadata]); + const overviewItems = useMemo( + () => [ + { + label: 'App', + value: feedback?.app || '--', + }, + { + label: 'Platform', + value: feedback?.platform || '--', + }, + { + label: 'Tenant', + value: feedback?.tenant || '--', + }, + { + label: 'Submission ID', + value: feedback?._id || '--', + }, + ], + [feedback?._id, feedback?.app, feedback?.platform, feedback?.tenant] + ); + + const allowedStatusOptions = useMemo(() => { + if (!feedback) { + return []; + } + + const allowedStatuses = new Set([ + feedback.status, + ...(ALLOWED_TRANSITIONS[feedback.status] || []), + ]); + + return STATUS_OPTIONS.filter(status => allowedStatuses.has(status)); + }, [feedback]); + + const rating = Math.max(0, Math.min(5, Number(feedback?.rating || 0))); + const headingSubtitle = `Submitted by ${feedback?.email || '--'} on ${formatDateTime( + feedback?.createdAt || new Date().toISOString() + )}`; + const handleBack = () => { router.push('/system/feedback'); }; @@ -168,153 +257,229 @@ const FeedbackDetailsContent: React.FC<{ feedbackId: string }> = ({ - -
-
- -
-
-

Current status

- + {STATUS_LABELS[feedback.status] || feedback.status} + + } + > +
+ + {CATEGORY_LABELS[feedback.category] || feedback.category} + + + {feedback.platform} + + {feedback.app ? ( + + {feedback.app} + + ) : null} + + + {Array.from({ length: 5 }, (_, index) => + index < rating ? ( + + ) : ( + + ) + )} + + {rating}/5 + +
+ + +
+ +
+ + +
+ + {feedback.email} + + + {formatDateTime(feedback.createdAt)} + + + {formatDateTime(feedback.updatedAt)} + + +
+ + {Array.from({ length: 5 }, (_, index) => + index < rating ? ( + + ) : ( + + ) + )} + + + {rating}/5 + +
+
+ {overviewItems.map(item => ( + - {STATUS_LABELS[feedback.status] || feedback.status} - -
+ {item.value} + + ))} +
+
+ + + +
+ + + + + {STATUS_LABELS[feedback.status] || feedback.status} + + + +
+ {allowedStatusOptions.map(status => ( + + ))} +
+
+
+
-
- {STATUS_OPTIONS.filter(status => { - const allowed = new Set([ - feedback.status, - ...(ALLOWED_TRANSITIONS[feedback.status] || []), - ]); - return allowed.has(status); - }).map(status => ( - + {entry.value} + ))}
-
-
- - -
-
-

Message

-

- {feedback.message} -

+ ) : ( +
+ No metadata was included with this submission.
+ )} +
+ +
-
-
-

- Category -

-

- {CATEGORY_LABELS[feedback.category] || feedback.category} -

-
-
-

- Platform -

-

- {feedback.platform} -

-
-
-

- Rating -

-

- {feedback.rating}/5 -

-
-
-

- Tenant -

-

- {feedback.tenant || '--'} -

-
+ +
+ + Open full image + + + ) : null + } + /> + + {feedback.screenshot_url ? ( +
+
+ {`Screenshot
- -
- -
- -
-
-

Reporter

-

- {feedback.email} + ) : ( +

+
+

+ No screenshot attached

-
- -
-

- Created -

-

- {formatDateTime(feedback.createdAt)} -

-
- -
-

- Updated -

-

- {formatDateTime(feedback.updatedAt)} +

+ This submission did not include an image, so review will rely + on the message and metadata.

- - - -
-

Metadata

-
- {metadataEntries.length > 0 ? ( - metadataEntries.map(([label, value]) => ( -
- - {label} - - - {value} - -
- )) - ) : ( -

- No metadata was included with this submission. -

- )} -
-
-
+ )}
-
+
); }; diff --git a/src/platform/src/app/(dashboard)/system/feedback/page.tsx b/src/platform/src/app/(dashboard)/system/feedback/page.tsx index a403c4f4a5..59ce7b4f21 100644 --- a/src/platform/src/app/(dashboard)/system/feedback/page.tsx +++ b/src/platform/src/app/(dashboard)/system/feedback/page.tsx @@ -66,6 +66,13 @@ const STATUS_OPTIONS = [ { value: 'archived', label: 'Archived' }, ]; +const APP_OPTIONS = [ + { value: 'all', label: 'All apps' }, + { value: 'Analytics', label: 'Analytics' }, + { value: 'beacon', label: 'beacon' }, + { value: 'vertex', label: 'vertex' }, +]; + const formatDateTime = (value: string) => new Date(value).toLocaleString('en-US', { year: 'numeric', @@ -81,6 +88,7 @@ const FeedbackListContent: React.FC = () => { const router = useRouter(); const [statusFilter, setStatusFilter] = useState('all'); const [categoryFilter, setCategoryFilter] = useState('all'); + const [appFilter, setAppFilter] = useState('all'); const fetchAllFeedbacks = async (opts: { status?: string | null; @@ -122,9 +130,10 @@ const FeedbackListContent: React.FC = () => { statusFilter === 'all' || feedback.status === statusFilter; const matchesCategory = categoryFilter === 'all' || feedback.category === categoryFilter; - return matchesStatus && matchesCategory; + const matchesApp = appFilter === 'all' || feedback.app === appFilter; + return matchesStatus && matchesCategory && matchesApp; }); - }, [categoryFilter, feedbacks, statusFilter]); + }, [appFilter, categoryFilter, feedbacks, statusFilter]); const tableData = useMemo( () => @@ -136,7 +145,7 @@ const FeedbackListContent: React.FC = () => { ); const statusCounts = useMemo(() => { - return feedbacks.reduce( + return filteredFeedbacks.reduce( (counts, feedback) => { counts.total += 1; if (feedback.status === 'pending') counts.pending += 1; @@ -147,7 +156,7 @@ const FeedbackListContent: React.FC = () => { }, { total: 0, pending: 0, reviewed: 0, resolved: 0, archived: 0 } ); - }, [feedbacks]); + }, [filteredFeedbacks]); const handleViewFeedback = useCallback( (feedbackId: string) => { @@ -317,7 +326,7 @@ const FeedbackListContent: React.FC = () => { ))}
-
+
+ +
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value); +const FEEDBACK_APP_NAME = 'Analytics'; +const SCREENSHOT_ACCEPT = [...PROFILE_IMAGE_ALLOWED_MIME_TYPES].join(','); +const HIGHLIGHT_COLOR = 'rgba(239, 68, 68, 0.25)'; +const HIGHLIGHT_STROKE = 'rgba(239, 68, 68, 0.9)'; -const FEEDBACK_SOURCE_TAG = 'From Analytics'; +const getCategoryLabel = (category: FeedbackCategory) => + CATEGORY_OPTIONS.find(option => option.value === category)?.label || + 'General'; -const appendFeedbackSourceTag = (message: string): string => { - const trimmedMessage = message.trim(); +const buildDefaultFeedbackSubject = (category: FeedbackCategory) => + `${FEEDBACK_APP_NAME} feedback - ${getCategoryLabel(category)}`; - if (!trimmedMessage) { - return trimmedMessage; - } +const isCancelledCaptureError = (error: unknown): boolean => { + const name = (error as { name?: string })?.name; + return name === 'AbortError' || name === 'NotAllowedError'; +}; - if ( - trimmedMessage.toLowerCase().endsWith(FEEDBACK_SOURCE_TAG.toLowerCase()) - ) { - return trimmedMessage; - } +const readFileAsDataUrl = (file: File): Promise => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + if (typeof reader.result === 'string') { + resolve(reader.result); + return; + } - return `${trimmedMessage}\n\n${FEEDBACK_SOURCE_TAG}`; + reject(new Error('Failed to read the selected screenshot.')); + }; + reader.onerror = () => { + reject( + reader.error || new Error('Failed to read the selected screenshot.') + ); + }; + reader.readAsDataURL(file); + }); + +const canvasToScreenshotCapture = ( + canvas: HTMLCanvasElement +): Promise<{ dataUrl: string; file: File; width: number; height: number }> => { + const dataUrl = canvas.toDataURL('image/jpeg', 0.9); + + return new Promise((resolve, reject) => { + canvas.toBlob( + blob => { + if (!blob) { + reject(new Error('Failed to create the screenshot file.')); + return; + } + + resolve({ + dataUrl, + file: new File([blob], 'feedback-screenshot.jpg', { + type: 'image/jpeg', + }), + width: canvas.width, + height: canvas.height, + }); + }, + 'image/jpeg', + 0.9 + ); + }); }; +const flattenAnnotations = ( + baseDataUrl: string, + rects: Rect[], + naturalWidth: number, + naturalHeight: number +): Promise<{ dataUrl: string; file: File }> => + new Promise((resolve, reject) => { + const sourceImage = new window.Image(); + + sourceImage.onload = async () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = naturalWidth; + canvas.height = naturalHeight; + + const context = canvas.getContext('2d'); + if (!context) { + reject(new Error('Failed to prepare the screenshot canvas.')); + return; + } + + context.drawImage(sourceImage, 0, 0); + + for (const rect of rects) { + context.fillStyle = HIGHLIGHT_COLOR; + context.fillRect(rect.x, rect.y, rect.w, rect.h); + context.strokeStyle = HIGHLIGHT_STROKE; + context.lineWidth = Math.max(2, naturalWidth / 500); + context.strokeRect(rect.x, rect.y, rect.w, rect.h); + } + + const { dataUrl, file } = await canvasToScreenshotCapture(canvas); + resolve({ dataUrl, file }); + } catch (error) { + reject(error); + } + }; + + sourceImage.onerror = () => { + reject(new Error('Failed to load the screenshot for annotation.')); + }; + + sourceImage.src = baseDataUrl; + }); + const getBrowserLabel = (): string => { if (typeof navigator === 'undefined') { return 'Unknown browser'; @@ -141,20 +245,249 @@ const buildFeedbackMetadata = (pathname: string) => { const resetFormState = () => ({ category: 'general' as FeedbackCategory, rating: 3, - subject: '', message: '', }); +interface ScreenshotAnnotatorProps { + baseDataUrl: string; + onConfirm: (rects: Rect[]) => void; + onCancel: () => void; +} + +const ScreenshotAnnotator: React.FC = ({ + baseDataUrl, + onConfirm, + onCancel, +}) => { + const canvasRef = useRef(null); + const imageRef = useRef(null); + const startRef = useRef<{ x: number; y: number } | null>(null); + const [rects, setRects] = useState([]); + const [drawing, setDrawing] = useState(false); + const [currentRect, setCurrentRect] = useState(null); + const [imageLoaded, setImageLoaded] = useState(false); + + useEffect(() => { + const nextImage = new window.Image(); + nextImage.onload = () => { + imageRef.current = nextImage; + setImageLoaded(true); + }; + nextImage.onerror = () => { + setImageLoaded(false); + }; + nextImage.src = baseDataUrl; + }, [baseDataUrl]); + + const redraw = useCallback(() => { + const canvas = canvasRef.current; + const sourceImage = imageRef.current; + if (!canvas || !sourceImage) { + return; + } + + const context = canvas.getContext('2d'); + if (!context) { + return; + } + + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(sourceImage, 0, 0, canvas.width, canvas.height); + + const allRects = currentRect ? [...rects, currentRect] : rects; + for (const rect of allRects) { + context.fillStyle = HIGHLIGHT_COLOR; + context.fillRect(rect.x, rect.y, rect.w, rect.h); + context.strokeStyle = HIGHLIGHT_STROKE; + context.lineWidth = 2; + context.strokeRect(rect.x, rect.y, rect.w, rect.h); + } + }, [currentRect, rects]); + + useEffect(() => { + if (!imageLoaded) { + return; + } + + redraw(); + }, [imageLoaded, redraw]); + + useEffect(() => { + const canvas = canvasRef.current; + const sourceImage = imageRef.current; + if (!canvas || !sourceImage || !imageLoaded) { + return; + } + + canvas.width = sourceImage.naturalWidth; + canvas.height = sourceImage.naturalHeight; + redraw(); + }, [imageLoaded, redraw]); + + const getPosition = (event: React.MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) { + return { x: 0, y: 0 }; + } + + const rect = canvas.getBoundingClientRect(); + const scaleX = canvas.width / rect.width; + const scaleY = canvas.height / rect.height; + + return { + x: (event.clientX - rect.left) * scaleX, + y: (event.clientY - rect.top) * scaleY, + }; + }; + + const handleMouseDown = (event: React.MouseEvent) => { + event.preventDefault(); + const position = getPosition(event); + startRef.current = position; + setDrawing(true); + setCurrentRect({ x: position.x, y: position.y, w: 0, h: 0 }); + }; + + const handleMouseMove = (event: React.MouseEvent) => { + if (!drawing || !startRef.current) { + return; + } + + const position = getPosition(event); + const x = Math.min(position.x, startRef.current.x); + const y = Math.min(position.y, startRef.current.y); + const w = Math.abs(position.x - startRef.current.x); + const h = Math.abs(position.y - startRef.current.y); + + setCurrentRect({ x, y, w, h }); + }; + + const handleMouseUp = (event: React.MouseEvent) => { + if (!drawing || !startRef.current) { + return; + } + + const position = getPosition(event); + const x = Math.min(position.x, startRef.current.x); + const y = Math.min(position.y, startRef.current.y); + const w = Math.abs(position.x - startRef.current.x); + const h = Math.abs(position.y - startRef.current.y); + + if (w > 5 && h > 5) { + setRects(previous => [...previous, { x, y, w, h }]); + } + + setCurrentRect(null); + setDrawing(false); + startRef.current = null; + }; + + return ( +
+
+
+ + + Draw rectangles to highlight areas of interest + + + - click and drag on the image + +
+ +
+ + + +
+
+ +
+ {!imageLoaded ? ( +

Loading screenshot...

+ ) : ( + + )} +
+ +
+ {rects.length === 0 + ? 'No highlights yet - drag on the image to add one' + : `${rects.length} highlight${rects.length > 1 ? 's' : ''} added`} +
+
+ ); +}; + export const FeedbackLauncher: React.FC = () => { const pathname = usePathname(); const { user } = useUser(); + const fileInputRef = useRef(null); const [isOpen, setIsOpen] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false); - const [email, setEmail] = useState(''); + const [isUploadingScreenshot, setIsUploadingScreenshot] = useState(false); + const [isCapturingScreenshot, setIsCapturingScreenshot] = useState(false); const [category, setCategory] = useState('bug'); const [rating, setRating] = useState(3); - const [subject, setSubject] = useState(''); const [message, setMessage] = useState(''); + const [rawDataUrl, setRawDataUrl] = useState(null); + const [rawDimensions, setRawDimensions] = useState<{ + width: number; + height: number; + } | null>(null); + const [rawScreenshotFile, setRawScreenshotFile] = useState(null); + const [screenshotDataUrl, setScreenshotDataUrl] = useState( + null + ); + const [screenshotFile, setScreenshotFile] = useState(null); + const [annotatorOpen, setAnnotatorOpen] = useState(false); + const [previewOpen, setPreviewOpen] = useState(false); const shouldHideLauncher = pathname.startsWith('/system/feedback'); @@ -184,30 +517,295 @@ export const FeedbackLauncher: React.FC = () => { useEffect(() => { if (!isOpen) { + setIsSubmitting(false); + setIsUploadingScreenshot(false); + setScreenshotFile(null); + setScreenshotDataUrl(null); + setRawDataUrl(null); + setRawDimensions(null); + setRawScreenshotFile(null); + setAnnotatorOpen(false); + setPreviewOpen(false); + setIsCapturingScreenshot(false); return; } - setEmail(user?.email || ''); const nextDefaults = resetFormState(); setCategory(nextDefaults.category); setRating(nextDefaults.rating); - setSubject(nextDefaults.subject); setMessage(nextDefaults.message); + setScreenshotFile(null); + setScreenshotDataUrl(null); + setRawDataUrl(null); + setRawDimensions(null); + setRawScreenshotFile(null); + setAnnotatorOpen(false); + setPreviewOpen(false); }, [isOpen, user?.email]); + useEffect(() => { + if (!previewOpen) { + return; + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setPreviewOpen(false); + } + }; + + window.addEventListener('keydown', handleKeyDown); + + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [previewOpen]); + + const handleScreenshotUpload = async (file: File, successMessage: string) => { + try { + validateImageFile(file, { + allowedMimeTypes: [...PROFILE_IMAGE_ALLOWED_MIME_TYPES], + maxFileSizeBytes: MAX_IMAGE_FILE_SIZE_BYTES, + }); + + const previewDataUrl = await readFileAsDataUrl(file); + setScreenshotFile(file); + setScreenshotDataUrl(previewDataUrl); + setRawDataUrl(null); + setRawDimensions(null); + setRawScreenshotFile(null); + setPreviewOpen(false); + toast.info(successMessage); + } catch (error) { + toast.error(getUserFriendlyErrorMessage(error)); + } + }; + + const handleUploadImageClick = () => { + fileInputRef.current?.click(); + }; + + const handleSelectScreenshotImage = ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0]; + event.target.value = ''; + + if (!file) { + return; + } + + void handleScreenshotUpload( + file, + 'Image attached. It will be uploaded when you send feedback.' + ); + }; + + const handleCaptureScreenshot = async () => { + if ( + typeof window === 'undefined' || + !navigator.mediaDevices?.getDisplayMedia + ) { + toast.error('Screenshot capture is not supported in this browser.'); + return; + } + + setPreviewOpen(false); + setIsCapturingScreenshot(true); + + await new Promise(resolve => { + window.setTimeout(resolve, 50); + }); + + let stream: MediaStream | null = null; + + try { + stream = await navigator.mediaDevices.getDisplayMedia({ + video: { displaySurface: 'browser' }, + audio: false, + } as DisplayMediaStreamOptions); + + const track = stream.getVideoTracks()[0]; + if (!track) { + throw new Error('Failed to start screen capture.'); + } + + await new Promise(resolve => { + if (track.readyState === 'live') { + resolve(); + return; + } + + const timeoutId = window.setTimeout(resolve, 3000); + track.addEventListener( + 'unmute', + () => { + window.clearTimeout(timeoutId); + resolve(); + }, + { once: true } + ); + }); + + await new Promise(resolve => { + window.setTimeout(resolve, 500); + }); + + let capture: + | { + dataUrl: string; + file: File; + width: number; + height: number; + } + | undefined; + + const ImageCaptureConstructor = ( + window as unknown as { + ImageCapture?: new (track: MediaStreamTrack) => { + grabFrame: () => Promise; + }; + } + ).ImageCapture; + + if (ImageCaptureConstructor) { + try { + const imageCapture = new ImageCaptureConstructor(track); + const bitmap = await imageCapture.grabFrame(); + const canvas = document.createElement('canvas'); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to prepare the screenshot canvas.'); + } + + context.drawImage(bitmap, 0, 0); + capture = await canvasToScreenshotCapture(canvas); + } catch (captureError) { + console.warn( + 'ImageCapture failed, falling back to video capture:', + captureError + ); + } + } + + if (!capture) { + const video = document.createElement('video'); + video.srcObject = stream; + video.autoplay = true; + video.playsInline = true; + video.muted = true; + + await new Promise(resolve => { + video.onloadedmetadata = () => { + void video.play().then(resolve).catch(resolve); + }; + window.setTimeout(resolve, 1000); + }); + + const canvas = document.createElement('canvas'); + canvas.width = video.videoWidth || window.innerWidth; + canvas.height = video.videoHeight || window.innerHeight; + + const context = canvas.getContext('2d'); + if (!context) { + throw new Error('Failed to prepare the screenshot canvas.'); + } + + context.drawImage(video, 0, 0, canvas.width, canvas.height); + capture = await canvasToScreenshotCapture(canvas); + } + + setRawDataUrl(capture.dataUrl); + setRawDimensions({ width: capture.width, height: capture.height }); + setRawScreenshotFile(capture.file); + setAnnotatorOpen(true); + } catch (error) { + if (!isCancelledCaptureError(error)) { + toast.error( + 'Failed to capture screenshot', + getUserFriendlyErrorMessage(error) + ); + } + } finally { + stream?.getTracks().forEach(track => track.stop()); + setIsCapturingScreenshot(false); + } + }; + + const handleAnnotatorConfirm = async (rects: Rect[]) => { + setAnnotatorOpen(false); + + if (!rawDataUrl || !rawDimensions || !rawScreenshotFile) { + return; + } + + try { + if (rects.length === 0) { + setScreenshotDataUrl(rawDataUrl); + setScreenshotFile(rawScreenshotFile); + } else { + const flattened = await flattenAnnotations( + rawDataUrl, + rects, + rawDimensions.width, + rawDimensions.height + ); + + setScreenshotDataUrl(flattened.dataUrl); + setScreenshotFile(flattened.file); + } + + setRawDataUrl(null); + setRawDimensions(null); + setRawScreenshotFile(null); + toast.success( + 'Screenshot attached', + 'Your screenshot is ready and will be uploaded when you send feedback.' + ); + } catch (error) { + toast.error( + 'Failed to process screenshot annotations', + getUserFriendlyErrorMessage(error) + ); + } + }; + + const handleAnnotatorCancel = () => { + setAnnotatorOpen(false); + setRawDataUrl(null); + setRawDimensions(null); + setRawScreenshotFile(null); + }; + + const handleRemoveScreenshot = () => { + setScreenshotFile(null); + setScreenshotDataUrl(null); + setRawDataUrl(null); + setRawDimensions(null); + setRawScreenshotFile(null); + setPreviewOpen(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }; + const handleSubmit = async () => { - const trimmedEmail = email.trim(); - const trimmedSubject = subject.trim(); + const trimmedEmail = String(user?.email || '').trim(); + const trimmedSubject = buildDefaultFeedbackSubject(category); const trimmedMessage = message.trim(); - const submissionMessage = appendFeedbackSourceTag(trimmedMessage); - if (!trimmedEmail || !trimmedSubject || !trimmedMessage) { - toast.error('Please complete the email, subject, and message fields.'); + if (!trimmedMessage) { + toast.error('Please describe your feedback before sending it.'); return; } - if (!isValidEmail(trimmedEmail)) { - toast.error('Please enter a valid email address.'); + if (!trimmedEmail) { + toast.error( + 'We could not find your account email. Please refresh and try again.' + ); return; } @@ -215,14 +813,38 @@ export const FeedbackLauncher: React.FC = () => { try { const metadata = buildFeedbackMetadata(pathname); + let screenshotUrl: string | undefined; + + if (screenshotFile) { + setIsUploadingScreenshot(true); + try { + const uploadResult = await uploadToCloudinary(screenshotFile, { + folder: 'feedback', + tags: ['feedback', FEEDBACK_APP_NAME.toLowerCase()], + allowedMimeTypes: [...PROFILE_IMAGE_ALLOWED_MIME_TYPES], + maxFileSizeBytes: MAX_IMAGE_FILE_SIZE_BYTES, + }); + + screenshotUrl = uploadResult.secure_url; + } catch { + toast.warning( + 'Screenshot upload failed', + 'Your feedback will still be submitted without the screenshot.' + ); + } finally { + setIsUploadingScreenshot(false); + } + } await feedbackService.submitFeedback({ email: trimmedEmail, subject: trimmedSubject, - message: submissionMessage, + message: trimmedMessage, rating, category, + app: FEEDBACK_APP_NAME, platform: 'web', + screenshot_url: screenshotUrl, metadata, }); @@ -241,94 +863,262 @@ export const FeedbackLauncher: React.FC = () => { return ( <> - setIsOpen(false)} - title="Share feedback" - subtitle="Help us improve your experience. Tell us what's working well, what could be better, or any problems you've faced." - size="xl" - primaryAction={{ - label: isSubmitting ? 'Sending...' : 'Send feedback', - onClick: handleSubmit, - loading: isSubmitting, - disabled: isSubmitting, - }} - secondaryAction={{ - label: 'Cancel', - onClick: () => setIsOpen(false), - disabled: isSubmitting, - variant: 'outlined', - }} - > -
-
- setEmail(String(event.target.value || ''))} - placeholder="you@airqo.net" - required - containerClassName="md:col-span-1" - /> - - - {CATEGORY_OPTIONS.map(option => ( - - ))} - - - setSubject(String(event.target.value || ''))} - placeholder="Bug: Export CSV button not working" - required - containerClassName="md:col-span-2" /> - setMessage(String(event.target.value || ''))} - placeholder="Describe what happened, what you expected, and any error details you saw." - rows={6} - required - containerClassName="md:col-span-2" - /> +
+ + + setMessage(String(event.target.value || ''))} + placeholder="Describe what happened, what you expected, and any error details you saw." + rows={6} + required + containerClassName="md:col-span-2" + /> + +
+
+

+ Screenshot +

+

+ A screenshot will help us better understand the issue. + Please don't include any sensitive information. +

+
+ +
+ {screenshotFile && screenshotDataUrl ? ( +
+ -
-
-

- Experience rating -

+
+
+ + + {screenshotFile.name} + + + ({(screenshotFile.size / 1024).toFixed(0)} KB) + +
+ +
+ +
+ + +
+
+ ) : ( +
+ + +
+ +
+
+ )} + +

+ PNG, JPG, WebP, or AVIF. Max 5MB. The image is uploaded only + when you send feedback. +

+
-
- +
+
+

+ Experience rating +

+
+ +
+ +
+ + ) : null} + + {annotatorOpen && rawDataUrl ? ( + { + void handleAnnotatorConfirm(rects); + }} + onCancel={handleAnnotatorCancel} + /> + ) : null} + + {previewOpen && screenshotDataUrl ? ( +
setPreviewOpen(false)} + role="dialog" + aria-modal="true" + aria-label="Screenshot preview" + > +
event.stopPropagation()} + > +
+ Screenshot preview +
+ +
- + ) : null} ); }; diff --git a/src/platform/src/shared/components/header/components/organization-selector.tsx b/src/platform/src/shared/components/header/components/organization-selector.tsx index 24c0d500d0..a4ec62697c 100644 --- a/src/platform/src/shared/components/header/components/organization-selector.tsx +++ b/src/platform/src/shared/components/header/components/organization-selector.tsx @@ -3,18 +3,21 @@ import * as React from 'react'; import { useState } from 'react'; import { useRouter } from 'next/navigation'; +import { useDispatch } from 'react-redux'; import { Avatar } from '@/shared/components/ui/avatar'; import { SearchField } from '@/shared/components/ui/search-field'; import Dialog from '@/shared/components/ui/dialog'; import { useUser } from '@/shared/hooks/useUser'; import { useUserActions } from '@/shared/hooks/useUserActions'; import { isDefaultAirQoGroup } from '@/shared/utils/groupUtils'; +import { startPendingGroupSwitch } from '@/shared/store/userSlice'; import { AqGrid01 } from '@airqo/icons-react'; export function OrganizationSelector() { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); const router = useRouter(); + const dispatch = useDispatch(); const { groups, activeGroup } = useUser(); const { switchGroup } = useUserActions(); const safeGroups = React.useMemo( @@ -28,24 +31,33 @@ export function OrganizationSelector() { const handleGroupSwitch = (groupId: string) => { const selectedGroup = safeGroups.find(g => g.id === groupId); if (!selectedGroup || selectedGroup.id === activeGroup?.id) return; + const organizationSlug = selectedGroup.organizationSlug?.trim(); + const destinationPath = + isDefaultAirQoGroup(selectedGroup) || !organizationSlug + ? '/user/home' + : `/org/${encodeURIComponent(organizationSlug)}/dashboard`; // Close dialog first handleClose(); + dispatch( + startPendingGroupSwitch({ + targetGroupId: selectedGroup.id, + targetGroupName: + selectedGroup.title || + selectedGroup.organizationSlug || + 'organization', + destinationPath, + startedAt: new Date().toISOString(), + }) + ); + // Switch group context (updates Redux + invalidates group-scoped SWR/RQ caches) // before navigating so that when the new page mounts, group context is already // correct and ActiveGroupGuard doesn't need to re-sync. switchGroup(selectedGroup); - // Navigate to the appropriate route for the selected group - if (isDefaultAirQoGroup(selectedGroup)) { - router.push('/user/home'); - } else { - const orgSlug = selectedGroup.organizationSlug; - if (orgSlug) { - router.push(`/org/${orgSlug}/dashboard`); - } - } + router.push(destinationPath); }; const filteredGroups = React.useMemo(() => { diff --git a/src/platform/src/shared/components/ui/group-switch-overlay.tsx b/src/platform/src/shared/components/ui/group-switch-overlay.tsx new file mode 100644 index 0000000000..895ada09c3 --- /dev/null +++ b/src/platform/src/shared/components/ui/group-switch-overlay.tsx @@ -0,0 +1,85 @@ +'use client'; + +import { useEffect } from 'react'; +import { usePathname } from 'next/navigation'; +import { useDispatch, useSelector } from 'react-redux'; +import { + selectActiveGroup, + selectPendingGroupSwitch, +} from '@/shared/store/selectors'; +import { clearPendingGroupSwitch } from '@/shared/store/userSlice'; +import { LoadingOverlay } from './loading-overlay'; + +const GROUP_SWITCH_FAILSAFE_MS = 15000; + +const normalizePath = (value?: string | null) => { + const normalized = (value || '/').split('?')[0].replace(/\/+$/, ''); + return normalized || '/'; +}; + +const matchesDestinationPath = (pathname: string, destinationPath: string) => { + const currentPath = normalizePath(pathname).toLowerCase(); + const targetPath = normalizePath(destinationPath).toLowerCase(); + + return currentPath === targetPath || currentPath.startsWith(`${targetPath}/`); +}; + +export function GroupSwitchOverlay() { + const dispatch = useDispatch(); + const pathname = usePathname(); + const activeGroup = useSelector(selectActiveGroup); + const pendingGroupSwitch = useSelector(selectPendingGroupSwitch); + + const hasReachedDestination = + !!pendingGroupSwitch && + activeGroup?.id === pendingGroupSwitch.targetGroupId && + matchesDestinationPath(pathname, pendingGroupSwitch.destinationPath); + + useEffect(() => { + if (!pendingGroupSwitch) { + return; + } + + if (hasReachedDestination) { + const releaseTimer = window.setTimeout(() => { + dispatch(clearPendingGroupSwitch()); + }, 120); + + return () => window.clearTimeout(releaseTimer); + } + + const startedAt = Date.parse(pendingGroupSwitch.startedAt); + if (!Number.isFinite(startedAt)) { + dispatch(clearPendingGroupSwitch()); + return; + } + + const elapsedMs = Date.now() - startedAt; + const remainingMs = GROUP_SWITCH_FAILSAFE_MS - elapsedMs; + + if (remainingMs <= 0) { + dispatch(clearPendingGroupSwitch()); + return; + } + + const failsafeTimer = window.setTimeout(() => { + dispatch(clearPendingGroupSwitch()); + }, remainingMs); + + return () => window.clearTimeout(failsafeTimer); + }, [dispatch, hasReachedDestination, pendingGroupSwitch, pathname]); + + if (!pendingGroupSwitch) { + return null; + } + + return ( + + ); +} + +export default GroupSwitchOverlay; diff --git a/src/platform/src/shared/components/ui/loading-overlay.tsx b/src/platform/src/shared/components/ui/loading-overlay.tsx index c38a1ea8a6..fe9be050bb 100644 --- a/src/platform/src/shared/components/ui/loading-overlay.tsx +++ b/src/platform/src/shared/components/ui/loading-overlay.tsx @@ -23,6 +23,14 @@ interface LoadingOverlayProps { * Delay (ms) before showing to reduce flicker on fast transitions */ delayMs?: number; + /** + * Optional heading displayed below the loader + */ + title?: string; + /** + * Optional supporting text displayed below the title + */ + description?: string; } const LoadingOverlay: React.FC = ({ @@ -30,8 +38,11 @@ const LoadingOverlay: React.FC = ({ className = '', opacity = 0.2, delayMs = 120, + title, + description, }) => { const [shouldRender, setShouldRender] = useState(isVisible && delayMs === 0); + const hasCopy = Boolean(title || description); useEffect(() => { if (!isVisible) { @@ -60,7 +71,24 @@ const LoadingOverlay: React.FC = ({ role="status" aria-live="polite" > -
+ {hasCopy ? ( +
+
+
+ {title ? ( +

{title}

+ ) : null} + {description ? ( +

{description}

+ ) : null} +
+
+ ) : ( +
+ )}
); }; diff --git a/src/platform/src/shared/components/ui/toast.tsx b/src/platform/src/shared/components/ui/toast.tsx index 441b39fbdb..cc942e4b26 100644 --- a/src/platform/src/shared/components/ui/toast.tsx +++ b/src/platform/src/shared/components/ui/toast.tsx @@ -1,3 +1,5 @@ +'use client'; + import { Toaster as Sonner, toast as sonnerToast } from 'sonner'; import React from 'react'; @@ -27,75 +29,148 @@ interface ToastBodyProps extends ToastOptions { const VARIANT_CONFIG: Record< ToastVariant, { - stripe: string; - titleColor: string; - icon?: React.ReactNode; + progress: string; + iconShell: string; + iconColor: string; + icon: React.ReactNode; } > = { success: { - stripe: 'bg-emerald-500', - titleColor: 'text-foreground', + progress: 'bg-emerald-500', + iconShell: + 'bg-emerald-50 ring-1 ring-emerald-100 dark:bg-emerald-500/12 dark:ring-emerald-500/20', + iconColor: 'text-emerald-600 dark:text-emerald-300', + icon: ( + + ), }, error: { - stripe: 'bg-rose-500', - titleColor: 'text-foreground', + progress: 'bg-rose-500', + iconShell: + 'bg-rose-50 ring-1 ring-rose-100 dark:bg-rose-500/12 dark:ring-rose-500/20', + iconColor: 'text-rose-600 dark:text-rose-300', + icon: ( + + ), }, warning: { - stripe: 'bg-amber-500', - titleColor: 'text-foreground', + progress: 'bg-amber-500', + iconShell: + 'bg-amber-50 ring-1 ring-amber-100 dark:bg-amber-500/12 dark:ring-amber-500/20', + iconColor: 'text-amber-600 dark:text-amber-300', + icon: ( + + ), }, info: { - stripe: 'bg-sky-500', - titleColor: 'text-foreground', + progress: 'bg-[rgb(var(--primary))]', + iconShell: + 'bg-primary/10 ring-1 ring-primary/10 dark:bg-primary/12 dark:ring-primary/20', + iconColor: 'text-primary dark:text-blue-300', + icon: ( + + ), }, }; const DEFAULT_DURATION = 5000; const DEFAULT_VARIANT: ToastVariant = 'info'; +const MIN_DURATION = 1800; // ============================================================================ -// ToastBody Component +// ToastCard Component // ============================================================================ -const ToastBody: React.FC = ({ +const ToastCard: React.FC = ({ title, description, variant = DEFAULT_VARIANT, + duration = DEFAULT_DURATION, onClose, }) => { const config = VARIANT_CONFIG[variant]; + const resolvedDuration = Math.max(duration, MIN_DURATION); + const progressStyle = { + ['--toast-duration' as string]: `${resolvedDuration}ms`, + } as React.CSSProperties; return ( -
- {/* Left colored stripe - with spacing from edge */} -
+
+
+
+
+ {config.icon} +
- {/* Content area */} -
-
- {title && ( -
- {title} -
- )} - {description && ( -
- {description} -
- )} +
+ {title && ( +
+ {title} +
+ )} + {description && ( +
+ {description} +
+ )} +
- {/* Close button */}
+ +
+
+
); }; -ToastBody.displayName = 'ToastBody'; +ToastCard.displayName = 'ToastCard'; // ============================================================================ // Toaster Component @@ -120,21 +206,34 @@ ToastBody.displayName = 'ToastBody'; const Toaster: React.FC = ({ ...props }) => { return ( - + <> + + + + ); }; @@ -151,24 +250,27 @@ const showToast = (options: ToastOptions) => { variant = DEFAULT_VARIANT, duration = DEFAULT_DURATION, } = options; + const resolvedDuration = Math.max(duration, MIN_DURATION); + const isError = variant === 'error'; const toastId = sonnerToast.custom( id => (
- sonnerToast.dismiss(id)} />
), - { duration } + { duration: Infinity } ); return toastId; diff --git a/src/platform/src/shared/providers/UserDataFetcher.tsx b/src/platform/src/shared/providers/UserDataFetcher.tsx index 91b2527abb..21eb760d4b 100644 --- a/src/platform/src/shared/providers/UserDataFetcher.tsx +++ b/src/platform/src/shared/providers/UserDataFetcher.tsx @@ -13,7 +13,6 @@ import { setError, clearUser, } from '@/shared/store/userSlice'; -import { normalizeUser, normalizeGroups } from '@/shared/utils/userUtils'; import { selectActiveGroup, selectUser, @@ -21,6 +20,7 @@ import { } from '@/shared/store/selectors'; import { useLogout } from '@/shared/hooks/useLogout'; import type { User } from '@/shared/types/api'; +import { normalizeUser, normalizeGroups } from '@/shared/utils/userUtils'; /** * Component that automatically fetches and stores user data when authenticated @@ -111,19 +111,17 @@ export function UserDataFetcher({ children }: { children: React.ReactNode }) { // Only process data when it actually changes and is valid if (data !== prevData && data?.users && data.users.length > 0) { - const user = data.users[0] as User; - const normalizedUser = normalizeUser(user); - const normalizedGroups = normalizeGroups(user.groups); + const fetchedUser = data.users[0] as User; + const normalizedUser = normalizeUser(fetchedUser); + const normalizedGroups = normalizeGroups(fetchedUser.groups); if (normalizedUser) { - // Batch dispatches to prevent multiple re-renders dispatch(setUser(normalizedUser)); dispatch(setGroups(normalizedGroups)); dispatch(setError(null)); } else { // Avoid keeping stale user data when backend responds with an invalid shape dispatch(clearUser()); - dispatch(setGroups([])); dispatch(setError('Invalid user data received from API')); } } diff --git a/src/platform/src/shared/providers/auth-provider.tsx b/src/platform/src/shared/providers/auth-provider.tsx index 75ec673a8c..4e4d6fcec9 100644 --- a/src/platform/src/shared/providers/auth-provider.tsx +++ b/src/platform/src/shared/providers/auth-provider.tsx @@ -8,7 +8,7 @@ import { } from 'next-auth/react'; import { useEffect, useRef, useCallback, useState } from 'react'; import { useRouter, usePathname, useSearchParams } from 'next/navigation'; -import { useDispatch, useSelector } from 'react-redux'; +import { useSelector } from 'react-redux'; import { LoadingOverlay } from '@/shared/components/ui/loading-overlay'; import { UserDataFetcher } from './UserDataFetcher'; import { @@ -43,6 +43,7 @@ import { verifyBackendOAuthSession, } from '@/shared/lib/oauth-session'; import { useUserActions } from '@/shared/hooks'; +import { GroupSwitchOverlay } from '@/shared/components/ui/group-switch-overlay'; // Component to guard and redirect based on active group for all pages function ActiveGroupGuard({ children }: { children: React.ReactNode }) { @@ -53,7 +54,6 @@ function ActiveGroupGuard({ children }: { children: React.ReactNode }) { // Do NOT call `dispatch(setActiveGroup(...))` here directly because that // bypasses the cache invalidation implemented in `useUserActions.switchGroup`. const { switchGroup } = useUserActions(); - const dispatch = useDispatch(); const activeGroup = useSelector(selectActiveGroup); const groups = useSelector(selectGroups); @@ -118,7 +118,6 @@ function ActiveGroupGuard({ children }: { children: React.ReactNode }) { activeGroup, activeGroupSlug, airqoGroup, - dispatch, groupForCurrentOrgPath, isAirQoActiveGroup, isOrgPath, @@ -230,12 +229,18 @@ const isPermissionScopedUnauthorizedPath = (url?: string): boolean => { const waitForNextAuthSession = async ( shouldContinue: () => boolean, + expectedAccessToken?: string | null, attempts = NEXTAUTH_SESSION_RETRY_ATTEMPTS, delayMs = NEXTAUTH_SESSION_RETRY_DELAY_MS ) => { for (let attempt = 0; attempt < attempts; attempt += 1) { const session = await getSession(); - if (session?.user) { + const sessionAccessToken = getSessionAccessTokenFromSession(session); + const hasExpectedSession = + session?.user && + (!expectedAccessToken || sessionAccessToken === expectedAccessToken); + + if (hasExpectedSession) { return session; } @@ -287,7 +292,6 @@ function AuthScopedCacheProviders({ children }: { children: React.ReactNode }) { function AuthWrapper({ children }: { children: React.ReactNode }) { const { data: session, status, update } = useSession(); - const router = useRouter(); const pathname = usePathname(); const searchParams = useSearchParams(); const activeGroup = useSelector(selectActiveGroup); @@ -613,7 +617,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { } redirectWithReload('/user/home'); - }, [status, isPublicRoute, activeGroup, router, pathname, callbackUrl]); + }, [status, isPublicRoute, activeGroup, pathname, callbackUrl]); useEffect(() => { if (status === 'authenticated') { @@ -655,6 +659,7 @@ function AuthWrapper({ children }: { children: React.ReactNode }) { return ( + {children} @@ -684,10 +689,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const bootstrap = async () => { try { const oauthTokenHandoff = consumeOAuthTokenHandoffFromUrl(); + const normalizedOAuthHandoffToken = oauthTokenHandoff?.token + ? normalizeOAuthAccessToken(oauthTokenHandoff.token) + : null; + if (oauthTokenHandoff?.token) { // A fresh OAuth fragment indicates a new sign-in attempt, not an // intentional logout, so remove any stale marker before bootstrapping. clearBackendOAuthSignedOutFlag(); + clearCachedSessionAccessToken(); const signInResult = await signIn('credentials', { redirect: false, @@ -707,7 +717,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const nextAuthSession = oauthTokenHandoff?.token - ? await waitForNextAuthSession(() => isMounted) + ? await waitForNextAuthSession( + () => isMounted, + normalizedOAuthHandoffToken + ) : await getSession(); if (!isMounted) return; diff --git a/src/platform/src/shared/store/selectors.ts b/src/platform/src/shared/store/selectors.ts index 50241db3bd..c379cc63b3 100644 --- a/src/platform/src/shared/store/selectors.ts +++ b/src/platform/src/shared/store/selectors.ts @@ -4,6 +4,8 @@ import type { RootState } from './index'; export const selectUser = (state: RootState) => state.user.user; export const selectGroups = (state: RootState) => state.user.groups; export const selectActiveGroup = (state: RootState) => state.user.activeGroup; +export const selectPendingGroupSwitch = (state: RootState) => + state.user.pendingGroupSwitch; export const selectUserLoading = (state: RootState) => state.user.isLoading; export const selectLoggingOut = (state: RootState) => state.user.isLoggingOut; export const selectUserError = (state: RootState) => state.user.error; diff --git a/src/platform/src/shared/store/userSlice.ts b/src/platform/src/shared/store/userSlice.ts index f93bd2b4bb..cc661beeab 100644 --- a/src/platform/src/shared/store/userSlice.ts +++ b/src/platform/src/shared/store/userSlice.ts @@ -5,6 +5,12 @@ interface UserState { user: NormalizedUser | null; groups: NormalizedGroup[]; activeGroup: NormalizedGroup | null; + pendingGroupSwitch: { + targetGroupId: string; + targetGroupName: string; + destinationPath: string; + startedAt: string; + } | null; isLoading: boolean; isLoggingOut: boolean; error: string | null; @@ -14,6 +20,7 @@ const initialState: UserState = { user: null, groups: [], activeGroup: null, + pendingGroupSwitch: null, isLoading: false, isLoggingOut: false, error: null, @@ -48,6 +55,8 @@ const userSlice = createSlice({ if (defaultGroup) { state.activeGroup = defaultGroup; + } else { + state.activeGroup = null; } }, setActiveGroup: (state, action: PayloadAction) => { @@ -69,10 +78,25 @@ const userSlice = createSlice({ setLoggingOut: (state, action: PayloadAction) => { state.isLoggingOut = action.payload; }, + startPendingGroupSwitch: ( + state, + action: PayloadAction<{ + targetGroupId: string; + targetGroupName: string; + destinationPath: string; + startedAt: string; + }> + ) => { + state.pendingGroupSwitch = action.payload; + }, + clearPendingGroupSwitch: state => { + state.pendingGroupSwitch = null; + }, clearUser: state => { state.user = null; state.groups = []; state.activeGroup = null; + state.pendingGroupSwitch = null; state.error = null; // Don't reset isLoggingOut here - it should be managed separately }, @@ -87,6 +111,8 @@ export const { setLoading, setError, setLoggingOut, + startPendingGroupSwitch, + clearPendingGroupSwitch, clearUser, } = userSlice.actions; diff --git a/src/platform/src/shared/types/api.ts b/src/platform/src/shared/types/api.ts index b596df293c..5dd34fb230 100644 --- a/src/platform/src/shared/types/api.ts +++ b/src/platform/src/shared/types/api.ts @@ -400,6 +400,8 @@ export interface FeedbackSubmission { subject: string; message: string; rating: number; + app?: string; + screenshot_url?: string; metadata?: FeedbackSubmissionMetadata; tenant?: string; createdAt: string; @@ -434,7 +436,9 @@ export interface SubmitFeedbackRequest { message: string; rating: number; category: string; + app?: string; platform: string; + screenshot_url?: string; metadata?: FeedbackSubmissionMetadata; }