Skip to content

Commit b43141e

Browse files
committed
feat: add fuzzy search function
1 parent 2ac5930 commit b43141e

File tree

22 files changed

+4259
-337
lines changed

22 files changed

+4259
-337
lines changed

.github/workflows/deploy-cloudflare-worker.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
echo "READ_PERMISSION=${{ vars.READ_PERMISSION }}" >> cloudflare-worker/.env
4848
echo "WRITE_PERMISSION=${{ vars.WRITE_PERMISSION }}" >> cloudflare-worker/.env
4949
echo "ADMIN_PERMISSION=${{ vars.ADMIN_PERMISSION }}" >> cloudflare-worker/.env
50+
echo "SEARCH_PERMISSION=${{ vars.SEARCH_PERMISSION }}" >> cloudflare-worker/.env
5051
echo "DB_BACKEND=${{ vars.DB_BACKEND }}" >> cloudflare-worker/.env
5152
echo "BACKUP_PERMISSION=${{ vars.BACKUP_PERMISSION }}" >> cloudflare-worker/.env
5253
echo "DB_MAX_IMAGE_SIZE=${{ vars.DB_MAX_IMAGE_SIZE }}" >> cloudflare-worker/.env
@@ -60,7 +61,7 @@ jobs:
6061
cp wrangler.jsonc wrangler.jsonc.bak
6162
set -a && source .env && set +a
6263
cat .env
63-
cat wrangler.jsonc.bak | sed 's|// "vars".*.|,"vars": { "PAYPAL_TRANSACTION_BASE_URL": "'"${PAYPAL_TRANSACTION_BASE_URL}"'", "AMAZON_BASE_URL": "'"${AMAZON_BASE_URL}"'", "AUTH0_DOMAIN": "'"${AUTH0_DOMAIN}"'", "AUTH0_CLIENT_ID": "'"${AUTH0_CLIENT_ID}"'", "AUTH0_SCOPE": "'"${AUTH0_SCOPE}"'", "AUTH0_AUDIENCE": "'"${AUTH0_AUDIENCE}"'", "API_BASE_URL": "'"${API_BASE_URL}"'", "CORS_ORIGIN": "'"${CORS_ORIGIN}"'", "DB_BACKEND": "'"${DB_BACKEND}"'", "DB_MAX_IMAGE_SIZE": "'"${DB_MAX_IMAGE_SIZE}"'", "BACKUP_PERMISSION": "'"${BACKUP_PERMISSION}"'", "READ_PERMISSION": "'"${READ_PERMISSION}"'", "WRITE_PERMISSION": "'"${WRITE_PERMISSION}"'", "ADMIN_PERMISSION": "'"${ADMIN_PERMISSION}"'", "STATISTICS_LIMIT": "'"${STATISTICS_LIMIT}"'" }|' > wrangler.jsonc.2
64+
cat wrangler.jsonc.bak | sed 's|// "vars".*.|,"vars": { "PAYPAL_TRANSACTION_BASE_URL": "'"${PAYPAL_TRANSACTION_BASE_URL}"'", "AMAZON_BASE_URL": "'"${AMAZON_BASE_URL}"'", "AUTH0_DOMAIN": "'"${AUTH0_DOMAIN}"'", "AUTH0_CLIENT_ID": "'"${AUTH0_CLIENT_ID}"'", "AUTH0_SCOPE": "'"${AUTH0_SCOPE}"'", "AUTH0_AUDIENCE": "'"${AUTH0_AUDIENCE}"'", "API_BASE_URL": "'"${API_BASE_URL}"'", "CORS_ORIGIN": "'"${CORS_ORIGIN}"'", "DB_BACKEND": "'"${DB_BACKEND}"'", "DB_MAX_IMAGE_SIZE": "'"${DB_MAX_IMAGE_SIZE}"'", "BACKUP_PERMISSION": "'"${BACKUP_PERMISSION}"'", "READ_PERMISSION": "'"${READ_PERMISSION}"'", "WRITE_PERMISSION": "'"${WRITE_PERMISSION}"'", "ADMIN_PERMISSION": "'"${ADMIN_PERMISSION}"'", "SEARCH_PERMISSION": "'"${SEARCH_PERMISSION}"'", "STATISTICS_LIMIT": "'"${STATISTICS_LIMIT}"'" }|' > wrangler.jsonc.2
6465
cat wrangler.jsonc.2 | sed 's|// "routes".*.|,"routes": [{ "pattern": "'"${DOMAIN_NAME}"'", "custom_domain": true }]|' > wrangler.jsonc
6566
cat wrangler.jsonc
6667
- name: Deploy
@@ -79,6 +80,7 @@ jobs:
7980
READ_PERMISSION
8081
WRITE_PERMISSION
8182
ADMIN_PERMISSION
83+
SEARCH_PERMISSION
8284
DB_BACKEND
8385
BACKUP_PERMISSION
8486
DB_MAX_IMAGE_SIZE
@@ -95,6 +97,7 @@ jobs:
9597
READ_PERMISSION: ${{ vars.READ_PERMISSION }}
9698
WRITE_PERMISSION: ${{ vars.WRITE_PERMISSION }}
9799
ADMIN_PERMISSION: ${{ vars.ADMIN_PERMISSION }}
100+
SEARCH_PERMISSION: ${{ vars.SEARCH_PERMISSION }}
98101
DB_BACKEND: ${{ vars.DB_BACKEND }}
99102
BACKUP_PERMISSION: ${{ vars.BACKUP_PERMISSION }}
100103
DB_MAX_IMAGE_SIZE: ${{ vars.DB_MAX_IMAGE_SIZE }}

.github/workflows/pages.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ jobs:
5353
export ADMIN_PERMISSION="${{ vars.ADMIN_PERMISSION }}"
5454
export BACKUP_PERMISSION="${{ vars.BACKUP_PERMISSION }}"
5555
export DB_MAX_IMAGE_SIZE="${{ vars.DB_MAX_IMAGE_SIZE }}"
56+
export SEARCH_PERMISSION="${{ vars.SEARCH_PERMISSION }}"
5657
export AMAZON_BASE_URL="${{ vars.AMAZON_BASE_URL }}"
5758
export PAYPAL_TRANSACTION_BASE_URL="${{ secrets.PAYPAL_TRANSACTION_BASE_URL }}"
5859
npx vite build --base=/${{ github.event.repository.name }}

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ There may be a delay between the purchase and the refund. Additionally, the refu
8181
| admin:api | Administer the API |
8282
| read:api | Read one's own feedback data |
8383
| write:api | Write one's own feedback data |
84+
| search:api | Search for feedback data |
8485
| backup:api | Manage the database |
8586

8687
## Application Features
@@ -95,6 +96,7 @@ There may be a delay between the purchase and the refund. Additionally, the refu
9596
- Record a refund (write:api)
9697
- View non-refunded feedbacks (read:api)
9798
- View refunded feedbacks (read:api)
99+
- Search purchases (search:api)
98100

99101
## Security
100102

client/public/openapi.json

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,75 @@
10391039
}
10401040
}
10411041
},
1042+
"/api/purchase/search": {
1043+
"post": {
1044+
"summary": "Search purchases using fuzzy matching",
1045+
"description": "Searches for purchases matching the query using fuzzy matching. Supports case-insensitive search, accent-insensitive search, and partial matches. Searches in purchase ID, order number, description, and amount.",
1046+
"tags": [
1047+
"Purchases"
1048+
],
1049+
"requestBody": {
1050+
"required": true,
1051+
"content": {
1052+
"application/json": {
1053+
"schema": {
1054+
"type": "object",
1055+
"properties": {
1056+
"query": {
1057+
"type": "string",
1058+
"description": "Search query (minimum 4 characters)",
1059+
"example": "amazon"
1060+
},
1061+
"limit": {
1062+
"type": "number",
1063+
"description": "Maximum number of results (default 50, max 1000)",
1064+
"default": 50
1065+
}
1066+
},
1067+
"required": [
1068+
"query"
1069+
]
1070+
}
1071+
}
1072+
}
1073+
},
1074+
"responses": {
1075+
"200": {
1076+
"description": "Search results",
1077+
"content": {
1078+
"application/json": {
1079+
"schema": {
1080+
"type": "object",
1081+
"properties": {
1082+
"success": {
1083+
"type": "boolean",
1084+
"example": true
1085+
},
1086+
"data": {
1087+
"type": "array",
1088+
"items": {
1089+
"type": "string"
1090+
},
1091+
"description": "Array of matching purchase IDs",
1092+
"example": [
1093+
"uuid-1",
1094+
"uuid-2"
1095+
]
1096+
}
1097+
}
1098+
}
1099+
}
1100+
}
1101+
},
1102+
"400": {
1103+
"description": "Invalid request"
1104+
},
1105+
"401": {
1106+
"description": "Unauthorized"
1107+
}
1108+
}
1109+
}
1110+
},
10421111
"/api/feedback": {
10431112
"post": {
10441113
"summary": "Add feedback",

client/src/components/navbar.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,12 @@ import { ThemeSwitch } from "@/components/theme-switch";
5555
import { ChevronDownIcon, GithubIcon } from "@/components/icons";
5656
import { Logo } from "@/components/icons";
5757
import { availableLanguages } from "@/i18n";
58+
import { SearchBar } from "./search-bar";
5859
export const Navbar = React.memo(() => {
5960
const { t } = useTranslation();
6061

62+
const searchInput = <SearchBar />;
63+
6164
return (
6265
<HeroUINavbar maxWidth="xl" position="sticky">
6366
<NavbarContent className="basis-1/5 sm:basis-full" justify="start">
@@ -177,9 +180,10 @@ export const Navbar = React.memo(() => {
177180
/>
178181
<LoginLogoutButton />
179182
</NavbarItem>
183+
<NavbarItem className="hidden lg:flex">{searchInput}</NavbarItem>
180184
</NavbarContent>
181185

182-
{/* Mobile Navbar */}
186+
{/* Mobile Navbar */}
183187
<NavbarContent className="sm:hidden basis-1 pl-4" justify="end">
184188
<Link isExternal href={siteConfig().links.github}>
185189
<GithubIcon className="text-default-500" />

client/src/components/paginated-table.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,15 @@ export interface PaginatedTableProps {
267267
* Useful for refreshing the table after external data modifications
268268
*/
269269
refreshTrigger?: any;
270+
/**
271+
* Optional manual data array. When provided, the table will use this data
272+
* instead of fetching from `dataUrl`. Useful for client-side search results.
273+
*/
274+
manualData?: any[];
275+
/**
276+
* When provided, sets the loading state while manualData is being fetched.
277+
*/
278+
manualIsLoading?: boolean;
270279
}
271280

272281
/**
@@ -463,6 +472,8 @@ export default function PaginatedTable({
463472
isSuccessfulResponse = (response) => response?.success === true,
464473
rowKey = "uuid",
465474
refreshTrigger,
475+
manualData,
476+
manualIsLoading,
466477
}: PaginatedTableProps) {
467478
const { t } = useTranslation();
468479
const { isAuthenticated } = useAuth0();
@@ -576,9 +587,33 @@ export default function PaginatedTable({
576587
* - External refresh trigger changes
577588
*/
578589
useEffect(() => {
590+
// If manualData is provided, skip server fetching
591+
if (manualData && manualData.length >= 0) return;
592+
579593
fetchData(page, limit, sort, order);
580594
}, [page, limit, sort, order, isAuthenticated, dataUrl, refreshTrigger]);
581595

596+
// When manualData is provided, use client-side slicing/pagination
597+
useEffect(() => {
598+
if (!manualData) return;
599+
600+
setIsLoading(!!manualIsLoading);
601+
setError(null);
602+
603+
const totalCount = manualData.length;
604+
setTotal(totalCount);
605+
606+
// Ensure page is in range
607+
if ((page - 1) * limit >= totalCount) {
608+
setPage(1);
609+
}
610+
611+
const start = (page - 1) * limit;
612+
const end = start + limit;
613+
const slice = manualData.slice(start, end);
614+
setItems(slice);
615+
}, [manualData, manualIsLoading, page, limit]);
616+
582617
/**
583618
* Handle sort change
584619
*/
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Ronan LE MEILLAT
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import { Input } from "@heroui/input";
26+
import { Kbd } from "@heroui/kbd";
27+
import { useTranslation } from "react-i18next";
28+
import { SearchIcon } from "@/components/icons";
29+
import { usePurchaseSearch } from "@/hooks/usePurchaseSearch";
30+
import { useSearch } from "@/context/SearchContext";
31+
import { AuthenticationGuardWithPermission } from "./auth0";
32+
33+
interface SearchBarProps {
34+
onSearchResults?: (results: string[]) => void;
35+
}
36+
37+
export const SearchBar = ({ onSearchResults }: SearchBarProps) => {
38+
const { t } = useTranslation();
39+
const { setSearchResults, setSearchQuery } = useSearch();
40+
41+
const handleSearchResultsReceived = (results: string[]) => {
42+
setSearchResults(results);
43+
onSearchResults?.(results);
44+
};
45+
46+
const { searchQuery, isSearching, handleSearchChange, clearSearch } =
47+
usePurchaseSearch(handleSearchResultsReceived);
48+
49+
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
50+
setSearchQuery(e.target.value);
51+
handleSearchChange(e.target.value);
52+
};
53+
54+
const handleClear = () => {
55+
clearSearch();
56+
setSearchQuery("");
57+
};
58+
59+
return (
60+
<AuthenticationGuardWithPermission
61+
key={`nav-search`}
62+
permission="search:api">
63+
<Input
64+
aria-label={t("search")}
65+
classNames={{
66+
inputWrapper: "bg-default-100",
67+
input: "text-sm",
68+
}}
69+
endContent={
70+
<Kbd className="hidden lg:inline-block" keys={["command"]}>
71+
K
72+
</Kbd>
73+
}
74+
labelPlacement="outside"
75+
placeholder={`${t("search")}…`}
76+
startContent={
77+
<SearchIcon className="text-base text-default-400 pointer-events-none flex-shrink-0" />
78+
}
79+
type="search"
80+
value={searchQuery}
81+
isDisabled={isSearching}
82+
onChange={handleChange}
83+
onClear={handleClear}
84+
isClearable
85+
/>
86+
</AuthenticationGuardWithPermission>
87+
);
88+
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* MIT License
3+
*
4+
* Copyright (c) 2025 Ronan LE MEILLAT
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
import { createContext, useContext, useState, ReactNode } from "react";
26+
27+
interface SearchContextType {
28+
searchResults: string[];
29+
setSearchResults: (results: string[]) => void;
30+
searchQuery: string;
31+
setSearchQuery: (query: string) => void;
32+
clearSearch: () => void;
33+
}
34+
35+
const SearchContext = createContext<SearchContextType | undefined>(undefined);
36+
37+
export const SearchProvider = ({ children }: { children: ReactNode }) => {
38+
const [searchResults, setSearchResults] = useState<string[]>([]);
39+
const [searchQuery, setSearchQuery] = useState<string>("");
40+
41+
const clearSearch = () => {
42+
setSearchResults([]);
43+
setSearchQuery("");
44+
};
45+
46+
return (
47+
<SearchContext.Provider
48+
value={{
49+
searchResults,
50+
setSearchResults,
51+
searchQuery,
52+
setSearchQuery,
53+
clearSearch,
54+
}}
55+
>
56+
{children}
57+
</SearchContext.Provider>
58+
);
59+
};
60+
61+
export const useSearch = () => {
62+
const context = useContext(SearchContext);
63+
if (context === undefined) {
64+
throw new Error("useSearch must be used within a SearchProvider");
65+
}
66+
return context;
67+
};

0 commit comments

Comments
 (0)