Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 81 additions & 39 deletions src/ui/pages/tickets/ScanTickets.page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
} from "@mantine/core";
import { IconAlertCircle, IconCheck, IconCamera } from "@tabler/icons-react";
import jsQR from "jsqr";
import React, { useEffect, useState, useRef } from "react";
// **MODIFIED**: Added useCallback
import React, { useEffect, useState, useRef, useCallback } from "react";
// **NEW**: Import useSearchParams to manage URL state
import { useSearchParams } from "react-router-dom";

import FullScreenLoader from "@ui/components/AuthContext/LoadingScreen";
import { AuthGuard } from "@ui/components/AuthGuard";
Expand Down Expand Up @@ -112,6 +115,9 @@ const ScanTicketsPageInternal: React.FC<ScanTicketsPageProps> = ({
checkInTicket: checkInTicketProp,
getEmailFromUIN: getEmailFromUINProp,
}) => {
// **NEW**: Initialize searchParams hooks
const [searchParams, setSearchParams] = useSearchParams();

const [orgList, setOrgList] = useState<string[] | null>(null);
const [showModal, setShowModal] = useState(false);
const [scanResult, setScanResult] = useState<APIResponseSchema | null>(null);
Expand Down Expand Up @@ -142,8 +148,9 @@ const ScanTicketsPageInternal: React.FC<ScanTicketsPageProps> = ({
group: string;
items: Array<{ value: string; label: string }>;
}> | null>(null);
// **NEW**: Read initial value from URL search param "itemId"
const [selectedItemFilter, setSelectedItemFilter] = useState<string | null>(
null,
searchParams.get("itemId") || null,
);
// **NEW**: State to hold the mapping of productId to friendly name
const [productNameMap, setProductNameMap] = useState<Map<string, string>>(
Expand All @@ -159,58 +166,66 @@ const ScanTicketsPageInternal: React.FC<ScanTicketsPageProps> = ({
const isScanningRef = useRef(false); // Use ref for immediate updates
const manualInputRef = useRef<HTMLInputElement | null>(null);

// Default API functions
const getOrganizations =
getOrganizationsProp ||
(async () => {
useCallback(async () => {
const response = await api.get("/api/v1/organizations");
return response.data;
});
}, [api]);

const getTicketItems =
getTicketItemsProp ||
(async () => {
useCallback(async () => {
const response = await api.get("/api/v1/tickets");
return response.data;
});
}, [api]);

const getPurchasesByEmail =
getPurchasesByEmailProp ||
(async (email: string) => {
const response = await api.get<PurchasesByEmailResponse>(
`/api/v1/tickets/purchases/${encodeURIComponent(email)}`,
);
return response.data;
});
useCallback(
async (email: string) => {
const response = await api.get<PurchasesByEmailResponse>(
`/api/v1/tickets/purchases/${encodeURIComponent(email)}`,
);
return response.data;
},
[api],
);

const checkInTicket =
checkInTicketProp ||
(async (data: any) => {
const response = await api.post(
`/api/v1/tickets/checkIn`,
recursiveToCamel(data),
);
return response.data as APIResponseSchema;
});
useCallback(
async (data: any) => {
const response = await api.post(
`/api/v1/tickets/checkIn`,
recursiveToCamel(data),
);
return response.data as APIResponseSchema;
},
[api],
);

const getEmailFromUINDefault = async (uin: string): Promise<string> => {
try {
const response = await api.post(`/api/v1/users/findUserByUin`, { uin });
return response.data.email;
} catch (error: any) {
const samp = new ValidationError({
message: "Failed to convert UIN to email.",
});
if (
error.response?.status === samp.httpStatusCode &&
error.response?.data.id === samp.id
) {
const validationData = error.response.data;
throw new ValidationError(validationData.message || samp.message);
const getEmailFromUINDefault = useCallback(
async (uin: string): Promise<string> => {
try {
const response = await api.post(`/api/v1/users/findUserByUin`, { uin });
return response.data.email;
} catch (error: any) {
const samp = new ValidationError({
message: "Failed to convert UIN to email.",
});
if (
error.response?.status === samp.httpStatusCode &&
error.response?.data.id === samp.id
) {
const validationData = error.response.data;
throw new ValidationError(validationData.message || samp.message);
}
throw error;
}
throw error;
}
};
},
[api],
);
Comment on lines 169 to +228
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix conditional hook calls - violates Rules of Hooks.

The pattern const fn = propFn || useCallback(...) violates the Rules of Hooks because the OR operator short-circuits. When propFn is truthy, useCallback is never called, changing hook call order between renders.

Apply this pattern to fix all instances:

-  const getOrganizations =
-    getOrganizationsProp ||
-    useCallback(async () => {
+  const getOrganizationsDefault = useCallback(async () => {
       const response = await api.get("/api/v1/organizations");
       return response.data;
     }, [api]);
+  const getOrganizations = getOrganizationsProp || getOrganizationsDefault;

-  const getTicketItems =
-    getTicketItemsProp ||
-    useCallback(async () => {
+  const getTicketItemsDefault = useCallback(async () => {
       const response = await api.get("/api/v1/tickets");
       return response.data;
     }, [api]);
+  const getTicketItems = getTicketItemsProp || getTicketItemsDefault;

-  const getPurchasesByEmail =
-    getPurchasesByEmailProp ||
-    useCallback(
-      async (email: string) => {
+  const getPurchasesByEmailDefault = useCallback(
+    async (email: string) => {
         const response = await api.get<PurchasesByEmailResponse>(
           `/api/v1/tickets/purchases/${encodeURIComponent(email)}`,
         );
         return response.data;
       },
       [api],
     );
+  const getPurchasesByEmail = getPurchasesByEmailProp || getPurchasesByEmailDefault;

-  const checkInTicket =
-    checkInTicketProp ||
-    useCallback(
-      async (data: any) => {
+  const checkInTicketDefault = useCallback(
+    async (data: any) => {
         const response = await api.post(
           `/api/v1/tickets/checkIn`,
           recursiveToCamel(data),
         );
         return response.data as APIResponseSchema;
       },
       [api],
     );
+  const checkInTicket = checkInTicketProp || checkInTicketDefault;

Note: getEmailFromUINDefault at lines 208-228 is already correct since it's always called unconditionally.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const getOrganizations =
getOrganizationsProp ||
(async () => {
useCallback(async () => {
const response = await api.get("/api/v1/organizations");
return response.data;
});
}, [api]);
const getTicketItems =
getTicketItemsProp ||
(async () => {
useCallback(async () => {
const response = await api.get("/api/v1/tickets");
return response.data;
});
}, [api]);
const getPurchasesByEmail =
getPurchasesByEmailProp ||
(async (email: string) => {
const response = await api.get<PurchasesByEmailResponse>(
`/api/v1/tickets/purchases/${encodeURIComponent(email)}`,
);
return response.data;
});
useCallback(
async (email: string) => {
const response = await api.get<PurchasesByEmailResponse>(
`/api/v1/tickets/purchases/${encodeURIComponent(email)}`,
);
return response.data;
},
[api],
);
const checkInTicket =
checkInTicketProp ||
(async (data: any) => {
const response = await api.post(
`/api/v1/tickets/checkIn`,
recursiveToCamel(data),
);
return response.data as APIResponseSchema;
});
useCallback(
async (data: any) => {
const response = await api.post(
`/api/v1/tickets/checkIn`,
recursiveToCamel(data),
);
return response.data as APIResponseSchema;
},
[api],
);
const getEmailFromUINDefault = async (uin: string): Promise<string> => {
try {
const response = await api.post(`/api/v1/users/findUserByUin`, { uin });
return response.data.email;
} catch (error: any) {
const samp = new ValidationError({
message: "Failed to convert UIN to email.",
});
if (
error.response?.status === samp.httpStatusCode &&
error.response?.data.id === samp.id
) {
const validationData = error.response.data;
throw new ValidationError(validationData.message || samp.message);
const getEmailFromUINDefault = useCallback(
async (uin: string): Promise<string> => {
try {
const response = await api.post(`/api/v1/users/findUserByUin`, { uin });
return response.data.email;
} catch (error: any) {
const samp = new ValidationError({
message: "Failed to convert UIN to email.",
});
if (
error.response?.status === samp.httpStatusCode &&
error.response?.data.id === samp.id
) {
const validationData = error.response.data;
throw new ValidationError(validationData.message || samp.message);
}
throw error;
}
throw error;
}
};
},
[api],
);
const getOrganizationsDefault = useCallback(async () => {
const response = await api.get("/api/v1/organizations");
return response.data;
}, [api]);
const getOrganizations = getOrganizationsProp || getOrganizationsDefault;
const getTicketItemsDefault = useCallback(async () => {
const response = await api.get("/api/v1/tickets");
return response.data;
}, [api]);
const getTicketItems = getTicketItemsProp || getTicketItemsDefault;
const getPurchasesByEmailDefault = useCallback(
async (email: string) => {
const response = await api.get<PurchasesByEmailResponse>(
`/api/v1/tickets/purchases/${encodeURIComponent(email)}`,
);
return response.data;
},
[api],
);
const getPurchasesByEmail = getPurchasesByEmailProp || getPurchasesByEmailDefault;
const checkInTicketDefault = useCallback(
async (data: any) => {
const response = await api.post(
`/api/v1/tickets/checkIn`,
recursiveToCamel(data),
);
return response.data as APIResponseSchema;
},
[api],
);
const checkInTicket = checkInTicketProp || checkInTicketDefault;
const getEmailFromUINDefault = useCallback(
async (uin: string): Promise<string> => {
try {
const response = await api.post(`/api/v1/users/findUserByUin`, { uin });
return response.data.email;
} catch (error: any) {
const samp = new ValidationError({
message: "Failed to convert UIN to email.",
});
if (
error.response?.status === samp.httpStatusCode &&
error.response?.data.id === samp.id
) {
const validationData = error.response.data;
throw new ValidationError(validationData.message || samp.message);
}
throw error;
}
},
[api],
);
🧰 Tools
🪛 Biome (2.1.2)

[error] 171-171: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 178-178: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 185-185: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


[error] 197-197: This hook is being called conditionally, but all hooks must be called in the exact same order in every component render.

For React to preserve state between calls, hooks needs to be called unconditionally and always in the same order.
See https://reactjs.org/docs/hooks-rules.html#only-call-hooks-at-the-top-level

(lint/correctness/useHookAtTopLevel)


const getEmailFromUIN = getEmailFromUINProp || getEmailFromUINDefault;

Expand Down Expand Up @@ -346,6 +361,18 @@ const ScanTicketsPageInternal: React.FC<ScanTicketsPageProps> = ({
}

setTicketItems(groups);

// After loading items, validate the item from the URL
const itemIdFromUrl = searchParams.get("itemId");
if (itemIdFromUrl) {
const allItems = groups.flatMap((g) => g.items);
if (allItems.some((item) => item.value === itemIdFromUrl)) {
setSelectedItemFilter(itemIdFromUrl);
} else {
setSelectedItemFilter(null);
setSearchParams({}, { replace: true });
}
}
} catch (err) {
console.error("Failed to fetch ticket items:", err);
setTicketItems([]);
Expand All @@ -363,7 +390,8 @@ const ScanTicketsPageInternal: React.FC<ScanTicketsPageProps> = ({
cancelAnimationFrame(animationFrameId.current);
}
};
}, []);
// **MODIFIED**: Added dependencies to useEffect
}, [getOrganizations, getTicketItems, searchParams, setSearchParams]);

const processVideoFrame = async (
video: HTMLVideoElement,
Expand Down Expand Up @@ -801,6 +829,19 @@ const ScanTicketsPageInternal: React.FC<ScanTicketsPageProps> = ({
setShowModal(true); // Show the main modal with results
};

// **NEW**: Memoize the onChange handler for the item filter Select
const handleItemFilterChange = useCallback(
(value: string | null) => {
setSelectedItemFilter(value);
if (value) {
setSearchParams({ itemId: value }, { replace: true });
} else {
setSearchParams({}, { replace: true });
}
},
[setSearchParams], // setSearchParams is stable
);

if (orgList === null || ticketItems === null) {
return <FullScreenLoader />;
}
Expand All @@ -819,7 +860,8 @@ const ScanTicketsPageInternal: React.FC<ScanTicketsPageProps> = ({
placeholder="Select an event or item to begin"
data={ticketItems}
value={selectedItemFilter}
onChange={setSelectedItemFilter}
// **MODIFIED**: Use the memoized handler
onChange={handleItemFilterChange}
searchable
disabled={isLoading}
w="100%"
Expand Down
Loading