From 96560d114c81317d018cabcc65c3ace573103669 Mon Sep 17 00:00:00 2001
From: John Ballesteros <johnshift.dev@gmail.com>
Date: Thu, 26 Sep 2024 10:51:27 +0800
Subject: [PATCH] refactor: add search components

---
 src/app/search/page.tsx                       |  1 +
 src/search/components/search-category.tsx     | 54 +++++++++++++++
 src/search/components/search-input.tsx        | 67 +++++++++++++++++++
 src/search/components/search-result.tsx       | 36 ++++++++++
 .../components/search-results-skeleton.tsx    | 29 ++++++++
 src/search/components/search-results.tsx      | 26 +++++++
 src/search/core/atoms.ts                      |  3 +
 src/search/core/query-keys.ts                 |  6 ++
 src/search/core/schemas.ts                    | 16 +++++
 src/search/data/search.ts                     |  6 ++
 src/search/hooks/use-search-input.ts          | 19 ++++++
 src/search/hooks/use-search-results.ts        | 24 +++++++
 src/search/pages/search-page.tsx              | 11 +++
 src/search/testutils/fake-search-results.ts   | 49 ++++++++++++++
 src/shared/components/icons/close-icon.tsx    | 18 +++++
 .../components/icons/seach-input-icon.tsx     | 20 ------
 16 files changed, 365 insertions(+), 20 deletions(-)
 create mode 100644 src/app/search/page.tsx
 create mode 100644 src/search/components/search-category.tsx
 create mode 100644 src/search/components/search-input.tsx
 create mode 100644 src/search/components/search-result.tsx
 create mode 100644 src/search/components/search-results-skeleton.tsx
 create mode 100644 src/search/components/search-results.tsx
 create mode 100644 src/search/core/atoms.ts
 create mode 100644 src/search/core/query-keys.ts
 create mode 100644 src/search/core/schemas.ts
 create mode 100644 src/search/data/search.ts
 create mode 100644 src/search/hooks/use-search-input.ts
 create mode 100644 src/search/hooks/use-search-results.ts
 create mode 100644 src/search/pages/search-page.tsx
 create mode 100644 src/search/testutils/fake-search-results.ts
 create mode 100644 src/shared/components/icons/close-icon.tsx
 delete mode 100644 src/shared/components/icons/seach-input-icon.tsx

diff --git a/src/app/search/page.tsx b/src/app/search/page.tsx
new file mode 100644
index 00000000..d842ceb0
--- /dev/null
+++ b/src/app/search/page.tsx
@@ -0,0 +1 @@
+export { SearchPage as default } from '@/search/pages/search-page';
diff --git a/src/search/components/search-category.tsx b/src/search/components/search-category.tsx
new file mode 100644
index 00000000..f51c08f9
--- /dev/null
+++ b/src/search/components/search-category.tsx
@@ -0,0 +1,54 @@
+'use client';
+
+import Link from 'next/link';
+
+import { Button } from '@nextui-org/react';
+
+import { ExternalIcon } from '@/shared/components/icons/external-icon';
+
+import { SearchResultDto } from '@/search/core/schemas';
+
+const escapeRegExp = (str: string): string =>
+  str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+const highlightText = (text: string, query: string): React.ReactNode => {
+  if (!query) return text;
+
+  const escapedQuery = escapeRegExp(query);
+  const regex = new RegExp(`(${escapedQuery})`, 'gi');
+  const parts = text.split(regex);
+
+  return (
+    <div className="">
+      {parts.map((part, index) =>
+        regex.test(part) ? (
+          <span key={index} className="font-bold text-[#98eebe]">
+            {part}
+          </span>
+        ) : (
+          <span key={index}>{part}</span>
+        ),
+      )}
+    </div>
+  );
+};
+
+type Category = SearchResultDto['categories'][number];
+
+interface Props extends Category {
+  query: string;
+}
+
+export const SearchCategory = ({ label, url, query }: Props) => {
+  return (
+    <Button
+      as={Link}
+      href={url}
+      size="sm"
+      className=""
+      endContent={<ExternalIcon />}
+    >
+      {highlightText(label, query)}
+    </Button>
+  );
+};
diff --git a/src/search/components/search-input.tsx b/src/search/components/search-input.tsx
new file mode 100644
index 00000000..5cc448e6
--- /dev/null
+++ b/src/search/components/search-input.tsx
@@ -0,0 +1,67 @@
+'use client';
+
+import { Button, Input } from '@nextui-org/react';
+
+import { CloseIcon } from '@/shared/components/icons/close-icon';
+import { SearchIcon } from '@/shared/components/icons/sidebar-search-icon';
+
+import { useSearchInput } from '@/search/hooks/use-search-input';
+
+export const SearchInput = () => {
+  const { value, onChange, onClear } = useSearchInput();
+
+  return (
+    <Input
+      placeholder="Search ..."
+      startContent={
+        <div className="shrink-0">
+          <SearchIcon />
+        </div>
+      }
+      endContent={
+        value ? (
+          <div className="flex items-center gap-2">
+            <span>Cancel</span>
+            <Button isIconOnly size="sm" onClick={onClear}>
+              <CloseIcon />
+            </Button>
+          </div>
+        ) : null
+      }
+      value={value}
+      onChange={onChange}
+    />
+  );
+};
+
+// 'use client';
+
+// import { Button } from '@nextui-org/react';
+
+// import { CloseIcon } from '@/shared/components/icons/close-icon';
+// import { SearchIcon } from '@/shared/components/icons/sidebar-search-icon';
+
+// import { useSearchInput } from '@/search/hooks/use-search-input';
+
+// export const SearchInput = () => {
+//   const { value, onChange } = useSearchInput();
+
+//   return (
+//     <div className="flex items-center gap-4 rounded-xl bg-white/5 p-2">
+//       <SearchIcon />
+//       <input
+//         type="text"
+//         placeholder="Search ..."
+//         className="size-full grow bg-transparent text-white/90"
+//         value={value}
+//         onChange={onChange}
+//       />
+//       <div className="flex items-center gap-2">
+//         <span>Cancel</span>
+//         <Button isIconOnly size="sm">
+//           <CloseIcon />
+//         </Button>
+//       </div>
+//     </div>
+//   );
+// };
diff --git a/src/search/components/search-result.tsx b/src/search/components/search-result.tsx
new file mode 100644
index 00000000..86051ea6
--- /dev/null
+++ b/src/search/components/search-result.tsx
@@ -0,0 +1,36 @@
+import { SearchResultDto } from '@/search/core/schemas';
+import { SearchCategory } from '@/search/components/search-category';
+
+export const SearchResultLayout = ({
+  label,
+  categories,
+}: {
+  label: React.ReactNode;
+  categories: React.ReactNode;
+}) => {
+  return (
+    <div className="flex flex-col gap-4">
+      {label}
+      <div className="flex flex-wrap gap-4">{categories}</div>
+    </div>
+  );
+};
+
+interface Props extends SearchResultDto {
+  query: string;
+}
+
+export const SearchResult = ({ query, title, categories }: Props) => {
+  return (
+    <SearchResultLayout
+      label={<span>{title}</span>}
+      categories={
+        <>
+          {categories.map(({ label, url }) => (
+            <SearchCategory key={label} query={query} label={label} url={url} />
+          ))}
+        </>
+      }
+    />
+  );
+};
diff --git a/src/search/components/search-results-skeleton.tsx b/src/search/components/search-results-skeleton.tsx
new file mode 100644
index 00000000..0c7b2ac3
--- /dev/null
+++ b/src/search/components/search-results-skeleton.tsx
@@ -0,0 +1,29 @@
+import { Fragment } from 'react';
+
+import { Skeleton } from '@nextui-org/react';
+
+import { Divider } from '@/shared/components/divider';
+
+import { SearchResultLayout } from '@/search/components/search-result';
+
+export const SearchResultsSkeleton = () => {
+  return (
+    <div className="flex flex-col gap-8">
+      {Array.from({ length: 5 }).map((_, i) => (
+        <Fragment key={i}>
+          <Divider />
+          <SearchResultLayout
+            label={<Skeleton className="h-5 w-20 rounded-md" />}
+            categories={
+              <>
+                {Array.from({ length: 5 }).map((_, i) => (
+                  <Skeleton key={i} className="h-9 w-40 rounded-lg" />
+                ))}
+              </>
+            }
+          />
+        </Fragment>
+      ))}
+    </div>
+  );
+};
diff --git a/src/search/components/search-results.tsx b/src/search/components/search-results.tsx
new file mode 100644
index 00000000..39c6e04c
--- /dev/null
+++ b/src/search/components/search-results.tsx
@@ -0,0 +1,26 @@
+'use client';
+
+import { Fragment } from 'react';
+
+import { Divider } from '@/shared/components/divider';
+
+import { useSearchResults } from '@/search/hooks/use-search-results';
+import { SearchResult } from '@/search/components/search-result';
+import { SearchResultsSkeleton } from '@/search/components/search-results-skeleton';
+
+export const SearchResults = () => {
+  const { query, data } = useSearchResults();
+
+  if (!data) return <SearchResultsSkeleton />;
+
+  return (
+    <div className="flex flex-col gap-8">
+      {data.map(({ title: label, categories }) => (
+        <Fragment key={label}>
+          <Divider />
+          <SearchResult query={query} title={label} categories={categories} />
+        </Fragment>
+      ))}
+    </div>
+  );
+};
diff --git a/src/search/core/atoms.ts b/src/search/core/atoms.ts
new file mode 100644
index 00000000..55e616af
--- /dev/null
+++ b/src/search/core/atoms.ts
@@ -0,0 +1,3 @@
+import { atom } from 'jotai';
+
+export const searchQueryAtom = atom<string>('');
diff --git a/src/search/core/query-keys.ts b/src/search/core/query-keys.ts
new file mode 100644
index 00000000..fcaad9b0
--- /dev/null
+++ b/src/search/core/query-keys.ts
@@ -0,0 +1,6 @@
+export const searchQueryKeys = {
+  all: ['search'] as const,
+  search: (query: string) => [...searchQueryKeys.all, 'search', query] as const,
+};
+
+export type SearchQueryKeys = typeof searchQueryKeys;
diff --git a/src/search/core/schemas.ts b/src/search/core/schemas.ts
new file mode 100644
index 00000000..20d3b663
--- /dev/null
+++ b/src/search/core/schemas.ts
@@ -0,0 +1,16 @@
+import { z } from 'zod';
+
+export const searchResultsDtoSchema = z.array(
+  z.object({
+    title: z.string(),
+    categories: z.array(
+      z.object({
+        label: z.string(),
+        url: z.string(),
+      }),
+    ),
+  }),
+);
+
+export type SearchResultsDto = z.infer<typeof searchResultsDtoSchema>;
+export type SearchResultDto = SearchResultsDto[number];
diff --git a/src/search/data/search.ts b/src/search/data/search.ts
new file mode 100644
index 00000000..8b14737a
--- /dev/null
+++ b/src/search/data/search.ts
@@ -0,0 +1,6 @@
+import { fakeSearchResults } from '@/search/testutils/fake-search-results';
+
+export const search = async (_query: string) => {
+  await new Promise((r) => setTimeout(r, 200));
+  return fakeSearchResults();
+};
diff --git a/src/search/hooks/use-search-input.ts b/src/search/hooks/use-search-input.ts
new file mode 100644
index 00000000..18959d6d
--- /dev/null
+++ b/src/search/hooks/use-search-input.ts
@@ -0,0 +1,19 @@
+import { useAtom } from 'jotai';
+
+import { searchQueryAtom } from '@/search/core/atoms';
+
+export const useSearchInput = () => {
+  const [value, setValue] = useAtom(searchQueryAtom);
+
+  const onChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
+    setValue(e.target.value);
+  };
+
+  const onClear = () => setValue('');
+
+  return {
+    value,
+    onChange,
+    onClear,
+  };
+};
diff --git a/src/search/hooks/use-search-results.ts b/src/search/hooks/use-search-results.ts
new file mode 100644
index 00000000..f5d643aa
--- /dev/null
+++ b/src/search/hooks/use-search-results.ts
@@ -0,0 +1,24 @@
+import { useQuery } from '@tanstack/react-query';
+import { useAtomValue } from 'jotai';
+
+import { QUERY_STALETIME } from '@/shared/core/constants';
+
+import { searchQueryKeys } from '@/search/core/query-keys';
+import { searchQueryAtom } from '@/search/core/atoms';
+import { search } from '@/search/data/search';
+
+export const useSearchResults = () => {
+  const query = useAtomValue(searchQueryAtom);
+
+  const fetchResult = useQuery({
+    // eslint-disable-next-line @tanstack/query/exhaustive-deps
+    queryKey: searchQueryKeys.search(query.toLowerCase()),
+    queryFn: () => search(query),
+    staleTime: QUERY_STALETIME.DEFAULT,
+  });
+
+  return {
+    query,
+    ...fetchResult,
+  };
+};
diff --git a/src/search/pages/search-page.tsx b/src/search/pages/search-page.tsx
new file mode 100644
index 00000000..78fefcda
--- /dev/null
+++ b/src/search/pages/search-page.tsx
@@ -0,0 +1,11 @@
+import { SearchInput } from '@/search/components/search-input';
+import { SearchResults } from '@/search/components/search-results';
+
+export const SearchPage = () => {
+  return (
+    <div className="flex flex-col gap-8 p-8">
+      <SearchInput />
+      <SearchResults />
+    </div>
+  );
+};
diff --git a/src/search/testutils/fake-search-results.ts b/src/search/testutils/fake-search-results.ts
new file mode 100644
index 00000000..80c16952
--- /dev/null
+++ b/src/search/testutils/fake-search-results.ts
@@ -0,0 +1,49 @@
+import { SearchResultsDto } from '@/search/core/schemas';
+
+export const fakeSearchResults = (): SearchResultsDto => {
+  return [
+    {
+      title: 'Jobs',
+      categories: [
+        { label: 'Creative Designer', url: '#' },
+        { label: 'Staff Product Designer', url: '#' },
+        { label: 'Design Customer Support', url: '#' },
+        { label: 'Cyber Design', url: '#' },
+        { label: 'Design Science', url: '#' },
+        { label: 'Design Engineering', url: '#' },
+      ],
+    },
+    {
+      title: 'Organizations',
+      categories: [
+        { label: 'Business Design', url: '#' },
+        { label: 'Golang Design', url: '#' },
+        { label: 'Smart Design Contracts', url: '#' },
+      ],
+    },
+    {
+      title: 'Projects',
+      categories: [
+        { label: 'Analysis Design', url: '#' },
+        { label: 'Project 1 Design', url: '#' },
+        { label: 'Project 2 Design', url: '#' },
+      ],
+    },
+    {
+      title: 'Categories',
+      categories: [
+        { label: 'Design Category 1', url: '#' },
+        { label: 'Design Category 2', url: '#' },
+        { label: 'Design Category 3', url: '#' },
+      ],
+    },
+    {
+      title: 'Skills',
+      categories: [
+        { label: 'Design Skill 1', url: '#' },
+        { label: 'Design Skill 2', url: '#' },
+        { label: 'Design Skill 3', url: '#' },
+      ],
+    },
+  ];
+};
diff --git a/src/shared/components/icons/close-icon.tsx b/src/shared/components/icons/close-icon.tsx
new file mode 100644
index 00000000..50c5fd33
--- /dev/null
+++ b/src/shared/components/icons/close-icon.tsx
@@ -0,0 +1,18 @@
+import { memo } from 'react';
+
+export const CloseIcon = memo(() => (
+  <svg
+    width="24"
+    height="24"
+    viewBox="0 0 24 24"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+  >
+    <path
+      d="M16.192 6.34375L11.949 10.5858L7.70697 6.34375L6.29297 7.75775L10.535 11.9998L6.29297 16.2418L7.70697 17.6558L11.949 13.4137L16.192 17.6558L17.606 16.2418L13.364 11.9998L17.606 7.75775L16.192 6.34375Z"
+      fill="white"
+    />
+  </svg>
+));
+
+CloseIcon.displayName = 'CloseIcon';
diff --git a/src/shared/components/icons/seach-input-icon.tsx b/src/shared/components/icons/seach-input-icon.tsx
deleted file mode 100644
index 0d1cdc92..00000000
--- a/src/shared/components/icons/seach-input-icon.tsx
+++ /dev/null
@@ -1,20 +0,0 @@
-import { memo } from 'react';
-
-export const SearchInputIcon = memo(() => (
-  <svg
-    xmlns="http://www.w3.org/2000/svg"
-    fill="none"
-    viewBox="0 0 24 24"
-    strokeWidth={1.5}
-    stroke="currentColor"
-    className="size-6"
-  >
-    <path
-      strokeLinecap="round"
-      strokeLinejoin="round"
-      d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607z"
-    />
-  </svg>
-));
-
-SearchInputIcon.displayName = 'SearchInputIcon';