diff --git a/client/app/[locale]/about/page.tsx b/client/app/[locale]/about/page.tsx deleted file mode 100644 index 46728f11..00000000 --- a/client/app/[locale]/about/page.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import { siteConfig } from "@/config/site" -import { Separator } from "@/components/ui/separator" - -export const metadata = siteConfig.page.about.metadata - -const AboutPage = () => { - return ( -
-
-

理念

-

- 海外搵工好多時都需要人脈,有人推薦先有面試,而外地愈嚟愈多港人,但缺乏相關文化同平台。呢個平台喺俾大家搵翻同聲同氣嘅,無論你係藍領白領,都希望大家互相幫忙。 -

-
- -
-

- 如何運作? -

-
-
-

- 1.加入人脈網絡 -

-
去個人檔案剔翻成為推薦人/受薦人。
-
如果網絡中搵到有適合人選,直接聯絡。
-
系統會Send訊息同埋對方電郵地址。
-
你哋私底下聯絡,睇吓有冇得搞。
-
祝一切順利!!
-
- -
-

2.貼街招

-
工作招聘網站見到合適工作/自己公司請人,想推薦香港人入。
-
將相關連結放上嚟。
-
睇吓有冇有緣人,如果有佢可以立即聯絡。
-
系統會Send訊息同埋對方電郵地址俾你。
-
你哋私底下聯絡,睇吓有冇得搞。
-
祝一切順利!!
-
-
-
-

- 過程唔會收錢,希望呢個平台幫到大家! -

-

- Btw 聯絡me : r1r69.referalah@gmail.com -

-
- ) -} - -export default AboutPage diff --git a/client/app/[locale]/auth/email-verification/page.tsx b/client/app/[locale]/auth/email-verification/page.tsx index 2078b3a7..1d3347f4 100644 --- a/client/app/[locale]/auth/email-verification/page.tsx +++ b/client/app/[locale]/auth/email-verification/page.tsx @@ -7,7 +7,10 @@ import CommonPageLayout from "@/components/layouts/common" const EmailVerificationPage = async () => { const t = await getI18n() return ( - + ) diff --git a/client/app/[locale]/auth/forgot-password/page.tsx b/client/app/[locale]/auth/forgot-password/page.tsx index 0fec99b8..de977b8c 100644 --- a/client/app/[locale]/auth/forgot-password/page.tsx +++ b/client/app/[locale]/auth/forgot-password/page.tsx @@ -10,7 +10,7 @@ export const metadata = siteConfig.page.forgetPassword.metadata const ForgotPasswordPage = async () => { const t = await getI18n() return ( - + ) diff --git a/client/app/[locale]/auth/reset-password/page.tsx b/client/app/[locale]/auth/reset-password/page.tsx index c76ce7bd..3850bb03 100644 --- a/client/app/[locale]/auth/reset-password/page.tsx +++ b/client/app/[locale]/auth/reset-password/page.tsx @@ -10,7 +10,7 @@ export const metadata = siteConfig.page.resetPassword.metadata const ResetPasswordPage = async () => { const t = await getI18n() return ( - + ) diff --git a/client/app/[locale]/auth/sign-in/page.tsx b/client/app/[locale]/auth/sign-in/page.tsx index d56e479d..7969f471 100644 --- a/client/app/[locale]/auth/sign-in/page.tsx +++ b/client/app/[locale]/auth/sign-in/page.tsx @@ -4,15 +4,18 @@ import { getI18n } from "@/utils/services/internationalization/server" import { siteConfig } from "@/config/site" import CommonPageLayout from "@/components/layouts/common" +import NotAuthOnlyWrapper from "@/components/wrappers/not-auth-only/not-auth-only" export const metadata = siteConfig.page.signIn.metadata const SignInPage = async () => { const t = await getI18n() return ( - - - + + + + + ) } export default SignInPage diff --git a/client/app/[locale]/auth/sign-up-confirmation/page.tsx b/client/app/[locale]/auth/sign-up-confirmation/page.tsx index faabd1de..d2c3fde5 100644 --- a/client/app/[locale]/auth/sign-up-confirmation/page.tsx +++ b/client/app/[locale]/auth/sign-up-confirmation/page.tsx @@ -10,7 +10,10 @@ export const metadata = siteConfig.page.signUpConfirmation.metadata const SignUpConfirmationPage = async () => { const t = await getI18n() return ( - + ) diff --git a/client/app/[locale]/auth/sign-up/page.tsx b/client/app/[locale]/auth/sign-up/page.tsx index a397dc1f..1984a2cb 100644 --- a/client/app/[locale]/auth/sign-up/page.tsx +++ b/client/app/[locale]/auth/sign-up/page.tsx @@ -4,15 +4,18 @@ import { getI18n } from "@/utils/services/internationalization/server" import { siteConfig } from "@/config/site" import CommonPageLayout from "@/components/layouts/common" +import NotAuthOnlyWrapper from "@/components/wrappers/not-auth-only/not-auth-only" export const metadata = siteConfig.page.signUp.metadata const SignUpPage = async () => { const t = await getI18n() return ( - - - + + + + + ) } diff --git a/client/app/[locale]/auth/verify-one-time-password/page.tsx b/client/app/[locale]/auth/verify-one-time-password/page.tsx index fbdd9357..85c13561 100644 --- a/client/app/[locale]/auth/verify-one-time-password/page.tsx +++ b/client/app/[locale]/auth/verify-one-time-password/page.tsx @@ -10,7 +10,10 @@ export const metadata = siteConfig.page.verifyOneTimePassword.metadata const VerifyOneTimePassword = async () => { const t = await getI18n() return ( - + ) diff --git a/client/app/[locale]/chat/page.tsx b/client/app/[locale]/chat/page.tsx index fb547ab9..da191ed7 100644 --- a/client/app/[locale]/chat/page.tsx +++ b/client/app/[locale]/chat/page.tsx @@ -1,7 +1,7 @@ import React from "react" import ChatPageTemplate from "@/modules/chat/template" -import AuthenticatedPageWrapper from "@/components/wrappers/authenticated" +import AuthenticatedPageWrapper from "@/components/wrappers/authenticated/authenticated" const ChatPage = () => { return ( diff --git a/client/app/[locale]/layout.tsx b/client/app/[locale]/layout.tsx index 381591ec..f25855ad 100644 --- a/client/app/[locale]/layout.tsx +++ b/client/app/[locale]/layout.tsx @@ -26,7 +26,7 @@ export const metadata: Metadata = { ], manifest: "../manifest.json", title: { - default: `${siteConfig.name} | 海外港人搵Referral平台`, + default: `${siteConfig.name} | 海外港人平台`, template: `%s - ${siteConfig.name}`, }, description: siteConfig.description, @@ -53,21 +53,23 @@ export default async function RootLayout({ }: RootLayoutProps) { return ( + -
+
-
- {children} -
+
{children}
diff --git a/client/app/[locale]/page.tsx b/client/app/[locale]/page.tsx index 37c0f70f..68af0345 100644 --- a/client/app/[locale]/page.tsx +++ b/client/app/[locale]/page.tsx @@ -1,29 +1,41 @@ +import { Suspense } from "react" import MainPageTemplate from "@/modules/main/template" import { getUserCount, listLatestContactRequest, - searchPostApi, + searchPost, } from "@/utils/common/api" import { EPostType } from "@/types/common/post-type" +import CommonPageLayout from "@/components/layouts/common" + +import Loading from "./loading" -// cache for 1 hours export const revalidate = 60 * 60 export default async function IndexPage() { - const count = await getUserCount() - const posts = await searchPostApi({ - numberOfDataPerPage: 8, - page: 0, - sortingType: "createdAt,dec", - companyName: "", - jobTitle: "", - maxYearOfExperience: 100, - minYearOfExperience: 0, - types: [EPostType.REFERRER, EPostType.HIRING, EPostType.COLLABORATION], - }) + return ( + + }> + + + + ) +} - const list = await listLatestContactRequest() +async function MainPageContent() { + const [count, posts, list] = await Promise.all([ + getUserCount(), + searchPost({ + keywords: "", + numberOfDataPerPage: 8, + experience: 0, + page: 0, + type: EPostType.ALL, + sortingType: "createdAt,dec", + }), + listLatestContactRequest(), + ]) return } diff --git a/client/app/[locale]/post/create/page.tsx b/client/app/[locale]/post/create/page.tsx index 2dab3733..06eae111 100644 --- a/client/app/[locale]/post/create/page.tsx +++ b/client/app/[locale]/post/create/page.tsx @@ -9,7 +9,7 @@ import { getI18n } from "@/utils/services/internationalization/server" import { siteConfig } from "@/config/site" import CommonPageLayout from "@/components/layouts/common" -import AuthenticatedPageWrapper from "@/components/wrappers/authenticated" +import AuthenticatedPageWrapper from "@/components/wrappers/authenticated/authenticated" export const metadata = siteConfig.page.createPost.metadata diff --git a/client/app/[locale]/post/view/[uuid]/page.tsx b/client/app/[locale]/post/view/[uuid]/page.tsx index f461a610..cc2c7edb 100644 --- a/client/app/[locale]/post/view/[uuid]/page.tsx +++ b/client/app/[locale]/post/view/[uuid]/page.tsx @@ -21,6 +21,7 @@ export async function generateMetadata({ // Define type title based on post type let typeTitle: string = "" + switch (postType) { case EPostType.HIRING: typeTitle = t("post.type.hiring.title") @@ -30,8 +31,6 @@ export async function generateMetadata({ break case EPostType.REFERRER: typeTitle = t("post.type.referer.title") - case EPostType.REFERRER: - typeTitle = t("post.type.collaboration.title") break case EPostType.COLLABORATION: typeTitle = t("post.type.collaboration.title") diff --git a/client/app/[locale]/profile/edit/page.tsx b/client/app/[locale]/profile/edit/page.tsx index e15a183a..9841682f 100644 --- a/client/app/[locale]/profile/edit/page.tsx +++ b/client/app/[locale]/profile/edit/page.tsx @@ -6,9 +6,10 @@ import { getIndustryList, getProvinceList, } from "@/utils/common/api" +import { getI18n } from "@/utils/services/internationalization/server" import CommonPageLayout from "@/components/layouts/common" -import AuthenticatedPageWrapper from "@/components/wrappers/authenticated" +import AuthenticatedPageWrapper from "@/components/wrappers/authenticated/authenticated" export const revalidate = 60 * 60 * 24 @@ -17,10 +18,10 @@ const EditProfilePage = async () => { const provinceList = await getProvinceList() const cityList = await getCityList() const industryList = await getIndustryList() - + const t = await getI18n() return ( - + ({ + ThemeProvider: jest.fn(({ children }) => ( +
{children}
+ )), + useTheme: jest.fn(), +})) + +describe("ThemeProvider", () => { + it("renders children without crashing", () => { + const { getByText } = render( + +
Test Child
+
+ ) + expect(getByText("Test Child")).toBeInTheDocument() + }) + + it("provides theme context to children", () => { + const MockChild = () => { + const { theme } = useTheme() + return
{theme}
+ } + + ;(useTheme as jest.Mock).mockReturnValue({ theme: "light" }) + + const { getByText } = render( + + + + ) + + expect(getByText("light")).toBeInTheDocument() + }) +}) diff --git a/client/components/__tests__/theme-toggle-mobile.test.tsx b/client/components/__tests__/theme-toggle-mobile.test.tsx new file mode 100644 index 00000000..042ede97 --- /dev/null +++ b/client/components/__tests__/theme-toggle-mobile.test.tsx @@ -0,0 +1,64 @@ +/** + * ThemeToggleMobile component test + * + * @group unit + */ + +import React from "react" +import { useI18n } from "@/utils/services/internationalization/client" +import { fireEvent, render, screen } from "@testing-library/react" +import { useTheme } from "next-themes" + +import { ThemeToggleMobile } from "@/components/theme-toggle-mobile" + +// Mock the next-themes hook +jest.mock("next-themes", () => ({ + useTheme: jest.fn(), +})) + +// Mock the internationalization hook +jest.mock("@/utils/services/internationalization/client", () => ({ + useI18n: jest.fn(), +})) + +describe("ThemeToggleMobile", () => { + const mockSetTheme = jest.fn() + const mockT = jest.fn((key) => key) + + beforeEach(() => { + jest.clearAllMocks() + ;(useTheme as jest.Mock).mockReturnValue({ + setTheme: mockSetTheme, + theme: "light", + }) + ;(useI18n as jest.Mock).mockReturnValue(mockT) + }) + + it("renders without crashing", () => { + render() + expect(screen.getByRole("switch")).toBeInTheDocument() + }) + + it('displays the sun icon and "Light Mode" text when theme is light', () => { + render() + expect(screen.getByText("general.light_mode")).toBeInTheDocument() + // expect(screen.getByText("sun")).toBeInTheDocument() + }) + + it('displays the moon icon and "Dark Mode" text when theme is dark', () => { + ;(useTheme as jest.Mock).mockReturnValue({ + setTheme: mockSetTheme, + theme: "dark", + }) + render() + expect(screen.getByText("general.dark_mode")).toBeInTheDocument() + // expect(screen.getByText("moon")).toBeInTheDocument() + }) + + it("toggles theme when switch is clicked", () => { + render() + const switchElement = screen.getByRole("switch") + fireEvent.click(switchElement) + expect(mockSetTheme).toHaveBeenCalledWith("dark") + }) +}) diff --git a/client/components/__tests__/theme-toggle.test.tsx b/client/components/__tests__/theme-toggle.test.tsx new file mode 100644 index 00000000..c210efb4 --- /dev/null +++ b/client/components/__tests__/theme-toggle.test.tsx @@ -0,0 +1,61 @@ +/** + * ThemeToggle component test + * + * @group unit + */ + +import React from "react" +import { fireEvent, render, screen } from "@testing-library/react" +import { useTheme } from "next-themes" + +import { ThemeToggle } from "@/components/theme-toggle" + +// Mock the next-themes hook +jest.mock("next-themes", () => ({ + useTheme: jest.fn(), +})) + +describe("ThemeToggle", () => { + const mockSetTheme = jest.fn() + + beforeEach(() => { + ;(useTheme as jest.Mock).mockReturnValue({ + setTheme: mockSetTheme, + theme: "light", + }) + }) + + it("renders without crashing", () => { + render() + expect(screen.getByRole("button")).toBeInTheDocument() + }) + + it("displays the sun icon when theme is light", () => { + render() + expect(screen.getByText("Toggle theme")).toBeInTheDocument() + expect(document.querySelector(".dark\\:hidden")).toBeInTheDocument() + }) + + it("displays the moon icon when theme is dark", () => { + ;(useTheme as jest.Mock).mockReturnValue({ + setTheme: mockSetTheme, + theme: "dark", + }) + render() + expect(screen.getByText("Toggle theme")).toBeInTheDocument() + expect(document.querySelector(".dark\\:block")).toBeInTheDocument() + }) + + it("toggles theme when clicked", () => { + render() + const button = screen.getByRole("button") + fireEvent.click(button) + expect(mockSetTheme).toHaveBeenCalledWith("dark") + }) + + it("applies custom className when provided", () => { + const customClass = "custom-class" + render() + expect(screen.getByRole("button")).toHaveClass(customClass) + }) +}) diff --git a/client/components/customized-ui/Infinite-scroll/base.tsx b/client/components/customized-ui/Infinite-scroll/base.tsx index ec40c525..0ec164ae 100644 --- a/client/components/customized-ui/Infinite-scroll/base.tsx +++ b/client/components/customized-ui/Infinite-scroll/base.tsx @@ -3,6 +3,7 @@ import { useI18n } from "@/utils/services/internationalization/client" import InfiniteScroll from "react-infinite-scroll-component" import { Badge } from "@/components/ui/badge" +import LoadingBalloonSpinner from "@/components/customized-ui/spinner/ball" interface IBaseInfiniteScrollProps { dataLength: number @@ -35,10 +36,8 @@ const BaseInfiniteScroll: React.FunctionComponent = ({ scrollableTarget={scrollableTarget} loader={ endMessage || ( -
- - {t("search.loading")} - +
+
) } diff --git a/client/components/customized-ui/avatars/base.tsx b/client/components/customized-ui/avatars/base.tsx index b9f6100e..7711724d 100644 --- a/client/components/customized-ui/avatars/base.tsx +++ b/client/components/customized-ui/avatars/base.tsx @@ -7,7 +7,7 @@ interface IBaseAvatar { url?: string alt: string | null fallBack: string | null - size?: "large" | "medium" + size?: "large" | "medium" | "veryLarge" className?: string } const BaseAvatar: React.FunctionComponent = ({ @@ -23,6 +23,7 @@ const BaseAvatar: React.FunctionComponent = ({ size === "large" && "h-24 w-24 text-2xl", size === "medium" && "h-12 w-12 text-2xl", + size === "veryLarge" && "h-44 w-44 text-3xl", className )} > diff --git a/client/components/customized-ui/badges/post-type.tsx b/client/components/customized-ui/badges/post-type.tsx new file mode 100644 index 00000000..7d0d4ad5 --- /dev/null +++ b/client/components/customized-ui/badges/post-type.tsx @@ -0,0 +1,30 @@ +import React from "react" +import usePostTypeTitle from "@/modules/post/hooks/post-type-title" + +import { EPostType } from "@/types/common/post-type" +import { cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" + +interface IPostTypeBadgeProps { + type: EPostType +} +const PostTypeBadge: React.FunctionComponent = ({ + type, +}) => { + const { title, bgColor, textColor } = usePostTypeTitle(type) + + return ( + + {title} + + ) +} + +export default PostTypeBadge diff --git a/client/components/customized-ui/badges/referee/referee.tsx b/client/components/customized-ui/badges/referee/referee.tsx new file mode 100644 index 00000000..d5264fbb --- /dev/null +++ b/client/components/customized-ui/badges/referee/referee.tsx @@ -0,0 +1,13 @@ +import React from "react" +import { useI18n } from "@/utils/services/internationalization/client" + +const RefereeBadge = () => { + const t = useI18n() + return ( +
+ {t("user.type.referee")} +
+ ) +} + +export default RefereeBadge diff --git a/client/components/customized-ui/badges/referrer/referrer.tsx b/client/components/customized-ui/badges/referrer/referrer.tsx new file mode 100644 index 00000000..b496d3a7 --- /dev/null +++ b/client/components/customized-ui/badges/referrer/referrer.tsx @@ -0,0 +1,13 @@ +import React from "react" +import { useI18n } from "@/utils/services/internationalization/client" + +const ReferrerBadge = () => { + const t = useI18n() + return ( +
+ {t("user.type.referrer")} +
+ ) +} + +export default ReferrerBadge diff --git a/client/components/customized-ui/bars/search.tsx b/client/components/customized-ui/bars/search.tsx index 968572be..0ea9b829 100644 --- a/client/components/customized-ui/bars/search.tsx +++ b/client/components/customized-ui/bars/search.tsx @@ -1,4 +1,6 @@ -import React, { ChangeEvent, KeyboardEventHandler } from "react" +import React, { ChangeEvent } from "react" +import IndustryCombobox from "@/modules/post/components/comboboxes/industry" +import LocationCombobox from "@/modules/post/components/comboboxes/location" import { useI18n } from "@/utils/services/internationalization/client" import { ICityResponse } from "@/types/api/response/city" @@ -7,183 +9,146 @@ import { IIndustryResponse } from "@/types/api/response/industry" import { IProvinceResponse } from "@/types/api/response/province" import { EMessageType } from "@/types/common/message-type" import { cn } from "@/lib/utils" -import useCityOptions from "@/hooks/common/options/city-options" -import useCountryOptions from "@/hooks/common/options/country-options" -import useIndustryOptions from "@/hooks/common/options/industry-options" -import useProvinceOptions from "@/hooks/common/options/province-options" import usePostSortOptions from "@/hooks/common/sort/post-sort-options" import useReferralSortOptions from "@/hooks/common/sort/referral-sort-options" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import ResetButton from "@/components/customized-ui/buttons/reset" +import ClearAllButton from "@/components/customized-ui/buttons/clear-all" +import TextInput from "@/components/customized-ui/inputs/text" import BaseSelect from "@/components/customized-ui/selects/base" +import YearOfExperienceSlider from "@/components/customized-ui/sliders/year-of-experience" +import { Icons } from "@/components/icons" export interface ISearchSearchBarProps { - countryUuid?: string - provinceUuid?: string - onCountryChange: (value: string) => void - onProvinceChange: (value: string) => void - onCityChange: (value: string) => void - onIndustryChange: (value: string) => void + onKeyWordsChange: (e: ChangeEvent) => void + onIndustryChange: (value: string[]) => void onSortingChange: (value: string) => void - onMinYearOfExperienceChange: (e: ChangeEvent) => void - onMaxYearOfExperienceChange: (e: ChangeEvent) => void - onSubmitChange: () => void - currentCountryUuid?: string - currentProvinceUuid?: string - currentCityUuid?: string - currentIndustryUuid?: string - currentMinYearOfExperience?: string - currentMaxYearOfExperience?: string currentSorting: string type: EMessageType countryList: ICountryResponse[] provinceList: IProvinceResponse[] cityList: ICityResponse[] industryList: IIndustryResponse[] - handleCompanyChange: (e: ChangeEvent) => void - handleKeyPressSubmitChange: KeyboardEventHandler - companyName: string - handleJobTitleChange: (e: ChangeEvent) => void - jobTitle: string + onLocationChange: (value: string[]) => void + locations: Set + industries: Set + keywords: string handleReset: () => void - handleSubmit: () => void bottomLeftSection?: React.ReactNode + experience: number + onExperienceChange: (value: number) => void } const SearchBar: React.FunctionComponent = ({ - provinceUuid, - countryUuid, - onCityChange, - onCountryChange, - onProvinceChange, onIndustryChange, onSortingChange, - onMinYearOfExperienceChange, - onMaxYearOfExperienceChange, - currentCountryUuid, - currentProvinceUuid, - currentCityUuid, - currentIndustryUuid, - currentMinYearOfExperience, - currentMaxYearOfExperience, + locations, + onLocationChange, currentSorting, type, cityList, countryList, industryList, provinceList, - companyName, - handleCompanyChange, - handleJobTitleChange, - handleKeyPressSubmitChange, - jobTitle, handleReset, - handleSubmit, bottomLeftSection, + onKeyWordsChange, + keywords, + industries, + onExperienceChange, + experience, }) => { const t = useI18n() - const industryOptions = useIndustryOptions(industryList, true) - const countryOptions = useCountryOptions(countryList, true) - const provinceOptions = useProvinceOptions(provinceList, countryUuid, true) - const cityOptions = useCityOptions(cityList, provinceUuid, true) const { data: postSortingOptions } = usePostSortOptions() const { data: referralSortingOptions } = useReferralSortOptions() return ( -
-
- - - - - -
- +
+
+ } + inputClassName="bg-slate-100" + placeholder={t("search.keywords.placeholder")} /> -

{t("search.year_of_experience.to")}

- -
+
+ +
+ +
+
- +
+ +
+ +
+
+
- +
+
+ +
+ +
+
- +
+ +
+ +
+
- +
+ +
+
{bottomLeftSection} -
- - -
) diff --git a/client/components/customized-ui/buttons/clear-all.tsx b/client/components/customized-ui/buttons/clear-all.tsx new file mode 100644 index 00000000..4e7bf18d --- /dev/null +++ b/client/components/customized-ui/buttons/clear-all.tsx @@ -0,0 +1,26 @@ +"use client" + +import React from "react" +import { useI18n } from "@/utils/services/internationalization/client" + +import { Button } from "@/components/ui/button" + +interface IClearAllButtonProps { + onClick: React.MouseEventHandler +} +const ClearAllButton: React.FunctionComponent = ({ + onClick, +}) => { + const t = useI18n() + return ( + + ) +} + +export default ClearAllButton diff --git a/client/components/customized-ui/buttons/contact.tsx b/client/components/customized-ui/buttons/contact.tsx index 75585891..9a8d144a 100644 --- a/client/components/customized-ui/buttons/contact.tsx +++ b/client/components/customized-ui/buttons/contact.tsx @@ -5,6 +5,7 @@ import { useI18n } from "@/utils/services/internationalization/client" import { EMessageType } from "@/types/common/message-type" import { EReferralType } from "@/types/common/referral-type" +import { cn } from "@/lib/utils" import useUserStore from "@/hooks/state/user/store" import { Button } from "@/components/ui/button" import { Icons } from "@/components/icons" @@ -15,6 +16,8 @@ interface IContactButtonProps { messageType: EMessageType postUuid?: string | null receiverType?: EReferralType + buttonClassName?: string + showIcon?: boolean } const ContactButton: React.FunctionComponent = ({ username, @@ -22,6 +25,8 @@ const ContactButton: React.FunctionComponent = ({ messageType, postUuid, receiverType, + buttonClassName, + showIcon = false, }) => { const t = useI18n() const [isContactFormOpen, setIsContactFormOpen] = useState(false) @@ -39,11 +44,12 @@ const ContactButton: React.FunctionComponent = ({ return ( <> -} -const ResetButton: React.FunctionComponent = ({ - onClick, -}) => { - const t = useI18n() - return ( - - ) -} - -export default ResetButton diff --git a/client/components/customized-ui/cards/post.tsx b/client/components/customized-ui/cards/post.tsx new file mode 100644 index 00000000..b83b384c --- /dev/null +++ b/client/components/customized-ui/cards/post.tsx @@ -0,0 +1,155 @@ +import React from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import PostStatusDisplay from "@/modules/post/components/info-display/status" + +import { TPostStatusType } from "@/types/common/post-status" +import { EPostType } from "@/types/common/post-type" +import { siteConfig } from "@/config/site" +import { cn } from "@/lib/utils" +import { Card, CardContent, CardFooter, CardHeader } from "@/components/ui/card" +import { Separator } from "@/components/ui/separator" +import BaseAvatar from "@/components/customized-ui/avatars/base" +import PostTypeBadge from "@/components/customized-ui/badges/post-type" +import ContactRequestCountIcon from "@/components/customized-ui/icons/contact-request-count" +import LinkIcon from "@/components/customized-ui/icons/link" +import CreatedAtDisplay from "@/components/customized-ui/info-display/created-at" +import IndustryDisplay from "@/components/customized-ui/info-display/industry" +import LocationDisplay from "@/components/customized-ui/info-display/location" +import YearsOfExperienceDisplay from "@/components/customized-ui/info-display/years-of-experience" + +interface IPostCardProps { + uuid: string | null + username: string | null + photoUrl: string | null + companyName: string | null + jobTitle: string | null + yearOfExperience?: number | null + country: string | null + province: string | null + city: string | null + industry?: string | null + url: string | null + createdAt: string | null + createdBy: string | null + className?: string + type: EPostType + requestCount: number + status?: TPostStatusType +} + +const PostCard: React.FunctionComponent = ({ + type, + uuid, + jobTitle, + city, + companyName, + country, + photoUrl, + province, + url, + industry, + username, + yearOfExperience, + createdAt, + createdBy, + className, + requestCount, + status, +}) => { + const router = useRouter() + + const handleAvatarOnClick = (e: React.MouseEvent) => { + e.preventDefault() + router.push(`${siteConfig.page.profile.href}/${createdBy}`) + } + + return ( + + e.stopPropagation()} + > + +
+

+ {jobTitle} +

+

{companyName}

+
+
+ + +
+
+ {status && } + +
+ +
+ 0 ? "active" : "inactive"} + /> + + +
+
+ +
+ {/* location, industry, year of exp */} +
+ {(city || province || country) && ( + + )} + + {industry && } + {typeof yearOfExperience === "number" && ( + + )} +
+
+ +
+ + {/* created at */} + +
+
+
+ +
+ +

{username}

+
+ +
+
+ +
+ ) +} + +export default PostCard diff --git a/client/components/customized-ui/cards/referral-post.tsx b/client/components/customized-ui/cards/referral-post.tsx deleted file mode 100644 index d9606cdb..00000000 --- a/client/components/customized-ui/cards/referral-post.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import React from "react" -import Link from "next/link" -import { useRouter } from "next/navigation" -import PostCardInfoDisplay from "@/modules/post/components/info-display/card-info" -import PostHeader from "@/modules/post/components/info-display/header" -import usePostTypeTitle from "@/modules/post/hooks/post-type-title" - -import { EPostType } from "@/types/common/post-type" -import { siteConfig } from "@/config/site" -import { cn } from "@/lib/utils" -import { Badge } from "@/components/ui/badge" -import { Card, CardFooter, CardHeader } from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" -import BaseAvatar from "@/components/customized-ui/avatars/base" -import ContactRequestCount from "@/components/customized-ui/icons/contact-request-count" -import CompanyNameDisplay from "@/components/customized-ui/info-display/company" -import CreatedAtDisplay from "@/components/customized-ui/info-display/created-at" - -interface IReferralPostCardProps { - uuid: string | null - username: string | null - photoUrl: string | null - companyName: string | null - jobTitle: string | null - yearOfExperience?: number | null - country: string | null - province: string | null - city: string | null - industry?: string | null - url: string | null - createdAt: string | null - createdBy: string | null - className?: string - type: EPostType - requestCount: number -} - -// NOTE: please use onClick with e.preventDefault() for any links inside this component to prevent validateDOMNesting warning - -const ReferralPostCard: React.FunctionComponent = ({ - type, - uuid, - jobTitle, - city, - companyName, - country, - industry, - photoUrl, - province, - url, - username, - yearOfExperience, - createdAt, - createdBy, - className, - requestCount, -}) => { - const router = useRouter() - - const handleAvatarOnClick = (e: React.MouseEvent) => { - e.preventDefault() - router.push(`${siteConfig.page.profile.href}/${createdBy}`) - } - - const postTypeTitle = usePostTypeTitle(type) - - return ( - - e.stopPropagation()} - className="flex h-full flex-col items-start justify-start" - > -
- - {/* title, subtitle, url, avatar, quick action */} -
-
- - ) : undefined - } - url={url} - /> -
- -
- -
-
- -
- {/* location, industry, year of exp */} - -
- -
-
- - {/* created at */} - - {postTypeTitle && ( - {postTypeTitle} - )} - - - -
- ) -} - -export default ReferralPostCard diff --git a/client/components/customized-ui/cards/referral.tsx b/client/components/customized-ui/cards/referral.tsx deleted file mode 100644 index 7a4cad09..00000000 --- a/client/components/customized-ui/cards/referral.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import React from "react" -import Link from "next/link" -import { useRouter } from "next/navigation" -import { useI18n } from "@/utils/services/internationalization/client" - -import { EMessageType } from "@/types/common/message-type" -import { EReferralType } from "@/types/common/referral-type" -import { siteConfig } from "@/config/site" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, -} from "@/components/ui/card" -import { Separator } from "@/components/ui/separator" -import BaseAvatar from "@/components/customized-ui/avatars/base" -import ContactButton from "@/components/customized-ui/buttons/contact" -import ContactRequestCount from "@/components/customized-ui/icons/contact-request-count" -import CompanyNameDisplay from "@/components/customized-ui/info-display/company" -import IndustryDisplay from "@/components/customized-ui/info-display/industry" -import LocationDisplay from "@/components/customized-ui/info-display/location" -import YearsOfExperienceDisplay from "@/components/customized-ui/info-display/years-of-experience" -import TooltipWrapper from "@/components/customized-ui/tool/tooltip-wrapper" -import { Icons } from "@/components/icons" - -interface IReferralCardProps { - uuid: string | null - username: string | null - photoUrl: string | null - description: string | null - companyName: string | null - jobTitle: string | null - yearOfExperience: number | null - country: string | null - province: string | null - city: string | null - industry: string | null - socialMediaUrl: string | null - receiverType: EReferralType - isReferrer: boolean - isReferee: boolean - requestCount: number -} - -const ReferralCard: React.FunctionComponent = ({ - jobTitle, - city, - companyName, - country, - description, - industry, - photoUrl, - province, - socialMediaUrl, - username, - uuid, - yearOfExperience, - receiverType, - isReferee, - isReferrer, - requestCount, -}) => { - const t = useI18n() - const router = useRouter() - - const handleProfileClick = () => { - router.push(`${siteConfig.page.profile.href}/${uuid}`) - } - - const handleUrlClick = () => { - if (socialMediaUrl) window.open(socialMediaUrl, "_blank") - } - - return ( - - {/* avatar, username , title, company, desc, url */} - -
-
-
- {isReferee && {t("user.type.referee")}} - {isReferrer && {t("user.type.referrer")}} -
-
-
- - {socialMediaUrl && ( -
- - - - } - tooltipContent={{t("general.personal_link")}} - /> -
- )} - - - - - -

@{username}

- - -

{jobTitle}

- {companyName && } -

- {description} -

-
- - {/* location, industry, year of exp */} - - - - {(city || province || country) && ( - - )} - {industry && } - {yearOfExperience !== null && ( - - )} - {requestCount > 0 && ( - - )} - - - {/* quick actions */} - - - - - - -
- ) -} - -export default ReferralCard diff --git a/client/components/customized-ui/comboboxes/base.tsx b/client/components/customized-ui/comboboxes/base.tsx new file mode 100644 index 00000000..0e02c3b1 --- /dev/null +++ b/client/components/customized-ui/comboboxes/base.tsx @@ -0,0 +1,51 @@ +"use client" + +import * as React from "react" +import { ChevronDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +export interface IComboboxOption { + value: string + label: string +} + +interface IBaseComboboxProps { + triggerTitle?: string + popoverClassName?: string +} +const BaseCombobox: React.FunctionComponent< + React.PropsWithChildren +> = ({ triggerTitle, children, popoverClassName }) => { + const [open, setOpen] = React.useState(false) + + return ( + + + + + + {children} + + + ) +} + +export default BaseCombobox diff --git a/client/components/customized-ui/drawers/search.tsx b/client/components/customized-ui/drawers/search.tsx deleted file mode 100644 index 9150fd54..00000000 --- a/client/components/customized-ui/drawers/search.tsx +++ /dev/null @@ -1,165 +0,0 @@ -"use client" - -import React, { useState } from "react" -import { useI18n } from "@/utils/services/internationalization/client" - -import useCityOptions from "@/hooks/common/options/city-options" -import useCountryOptions from "@/hooks/common/options/country-options" -import useIndustryOptions from "@/hooks/common/options/industry-options" -import useProvinceOptions from "@/hooks/common/options/province-options" -import usePostSortOptions from "@/hooks/common/sort/post-sort-options" -import useReferralSortOptions from "@/hooks/common/sort/referral-sort-options" -import { Button } from "@/components/ui/button" -import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer" -import { Input } from "@/components/ui/input" -import { ISearchSearchBarProps } from "@/components/customized-ui/bars/search" -import ResetButton from "@/components/customized-ui/buttons/reset" -import BaseSelect from "@/components/customized-ui/selects/base" -import { Icons } from "@/components/icons" - -export interface ISearchDrawerProps extends ISearchSearchBarProps { - additionalFields?: React.ReactNode -} -const SearchDrawer: React.FunctionComponent = ({ - provinceUuid, - countryUuid, - onCityChange, - onCountryChange, - onProvinceChange, - onIndustryChange, - onSortingChange, - onMinYearOfExperienceChange, - onMaxYearOfExperienceChange, - currentCountryUuid, - currentProvinceUuid, - currentCityUuid, - currentIndustryUuid, - currentMinYearOfExperience, - currentMaxYearOfExperience, - currentSorting, - type, - cityList, - countryList, - industryList, - provinceList, - companyName, - handleCompanyChange, - handleJobTitleChange, - handleKeyPressSubmitChange, - jobTitle, - handleReset, - handleSubmit, - additionalFields, -}) => { - const t = useI18n() - const industryOptions = useIndustryOptions(industryList, true) - const countryOptions = useCountryOptions(countryList, true) - const provinceOptions = useProvinceOptions(provinceList, countryUuid, true) - const cityOptions = useCityOptions(cityList, provinceUuid, true) - const { data: postSortingOptions } = usePostSortOptions() - const { data: referralSortingOptions } = useReferralSortOptions() - const [open, setOpen] = useState(false) - - const handleSearchSubmit = () => { - handleSubmit() - setOpen(false) - } - return ( - - -
- - {t("general.filter")} -
-
- -
- {additionalFields} - - - - - -
- -

{t("search.year_of_experience.to")}

- -
- - - - - - - - -
-
- - -
-
-
- ) -} - -export default SearchDrawer diff --git a/client/components/customized-ui/footer/nav.tsx b/client/components/customized-ui/footer/nav.tsx index 24977227..a52827cf 100644 --- a/client/components/customized-ui/footer/nav.tsx +++ b/client/components/customized-ui/footer/nav.tsx @@ -12,77 +12,78 @@ import { Icons } from "@/components/icons" const NavFooter = () => { const t = useI18n() const pathname = usePathname() - const noShowFooter = pathname.includes(siteConfig.page.chat.href) + + const hideFooterPageList = [ + siteConfig.page.chat.href, + siteConfig.page.profile.href, + siteConfig.page.editProfile.href, + ] + + const noShowFooter = hideFooterPageList.some((path) => + pathname.includes(path) + ) return (
-
-
- - - +
+ + + - - - + + + - - - -
+ + + +
-
- - {t("page.contributors")} - +
+ + {t("page.contributors")} + - - {t("page.installation")} - + + {t("page.installation")} + - - {t("page.about")} - - - {t("auth.sign_up.privacy_policy")} - + + {t("auth.sign_up.privacy_policy")} + - - {t("auth.sign_up.terms_and_conditions")} - -
+ + {t("auth.sign_up.terms_and_conditions")} +
) diff --git a/client/components/customized-ui/form/check-box.tsx b/client/components/customized-ui/form/check-box.tsx index c592fa78..95d08a41 100644 --- a/client/components/customized-ui/form/check-box.tsx +++ b/client/components/customized-ui/form/check-box.tsx @@ -16,6 +16,8 @@ interface ICheckBoxProps { name: string label: string description?: string + checkBoxClassName?: string + labelClassName?: string } const FormCheckBox: React.FunctionComponent = ({ @@ -23,6 +25,8 @@ const FormCheckBox: React.FunctionComponent = ({ name, label, description, + checkBoxClassName, + labelClassName, }) => { return ( = ({ render={({ field }) => ( - +
- {label} + {label} {description && {description}}
diff --git a/client/components/customized-ui/form/file.tsx b/client/components/customized-ui/form/file.tsx index 1588d9b4..0f7996c1 100644 --- a/client/components/customized-ui/form/file.tsx +++ b/client/components/customized-ui/form/file.tsx @@ -1,14 +1,16 @@ import React from "react" +import { cn } from "@/lib/utils" import { FormControl, FormDescription, FormLabel } from "@/components/ui/form" import { Input } from "@/components/ui/input" interface IInputFormFieldProps { - label: string + label?: string placeholder?: string description?: string onChange: (e: any) => void accept: string + className?: string } const FormFileUpload: React.FunctionComponent = ({ @@ -17,10 +19,12 @@ const FormFileUpload: React.FunctionComponent = ({ description, onChange, accept, + className, }) => { return ( -
- {label} +
+ {label && {label}} + = ({ key={option.value} className={cn(itemClassName)} > - {option.title} + {option.label} ))} diff --git a/client/components/customized-ui/form/switch.tsx b/client/components/customized-ui/form/switch.tsx new file mode 100644 index 00000000..ec3ff790 --- /dev/null +++ b/client/components/customized-ui/form/switch.tsx @@ -0,0 +1,57 @@ +import React from "react" +import { Control } from "react-hook-form" + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, +} from "@/components/ui/form" +import { Switch } from "@/components/ui/switch" + +interface FormSwitchProps { + control: Control + name: string + label?: string + description?: string + checked: boolean + onCheckedChange: (checked: boolean) => void + disabled?: boolean +} + +const FormSwitch: React.FC = ({ + control, + name, + label, + description, + checked, + onCheckedChange, + disabled = false, +}) => { + return ( + ( + +
+ {label && {label}} + {description && {description}} +
+ + + +
+ )} + /> + ) +} + +export default FormSwitch diff --git a/client/components/customized-ui/form/text-area.tsx b/client/components/customized-ui/form/text-area.tsx index 3ad6c48d..a124dce0 100644 --- a/client/components/customized-ui/form/text-area.tsx +++ b/client/components/customized-ui/form/text-area.tsx @@ -1,4 +1,5 @@ -import React from "react" +import React, { useEffect, useRef } from "react" +import { UseFormReturn } from "react-hook-form" import { FormControl, @@ -11,14 +12,39 @@ import { import { Textarea } from "@/components/ui/textarea" import { IFormTextInputProps } from "@/components/customized-ui/form/input" -interface IFormTextArea extends IFormTextInputProps {} +interface IFormTextArea extends Omit { + inputClassName?: string + minRows?: number + control: UseFormReturn["control"] +} + const FormTextArea: React.FunctionComponent = ({ control, name, label, placeholder, description, + inputClassName, + minRows = 3, }) => { + const textareaRef = useRef(null) + + const adjustHeight = () => { + const textarea = textareaRef.current + if (textarea) { + if (textarea.value.trim() === "") { + textarea.style.height = `${minRows * 24}px` // Assuming 24px line height + } else { + textarea.style.height = "auto" + textarea.style.height = `${textarea.scrollHeight}px` + } + } + } + + useEffect(() => { + adjustHeight() + }, [textareaRef.current?.value]) + return ( = ({ {label} -