diff --git a/package-lock.json b/package-lock.json index 0611a41..d7eb536 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,6 +33,7 @@ "lucide-react": "^0.309.0", "micro": "^9.4.1", "next": "14.0.4", + "qs": "^6.11.2", "react": "^18", "react-day-picker": "^8.10.0", "react-dom": "^18", @@ -50,6 +51,7 @@ "@graphql-codegen/typescript-react-apollo": "^4.1.0", "@types/better-sqlite3": "^7.6.9", "@types/node": "^20", + "@types/qs": "^6.9.11", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", @@ -5269,6 +5271,12 @@ "integrity": "sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==", "devOptional": true }, + "node_modules/@types/qs": { + "version": "6.9.11", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.11.tgz", + "integrity": "sha512-oGk0gmhnEJK4Yyk+oI7EfXsLayXatCWPHary1MtcmbAifkobT9cM9yutG/hZKIseOU0MqbIwQ/u2nn/Gb+ltuQ==", + "dev": true + }, "node_modules/@types/react": { "version": "18.2.45", "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.45.tgz", @@ -6512,7 +6520,6 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.5.tgz", "integrity": "sha512-C3nQxfFZxFRVoJoGKKI8y3MOEo129NQ+FgQ08iye+Mk4zNZZGdjfs06bVTr+DBSlA66Q2VEcMki/cUCP4SercQ==", - "dev": true, "dependencies": { "function-bind": "^1.1.2", "get-intrinsic": "^1.2.1", @@ -7249,7 +7256,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.1.tgz", "integrity": "sha512-E7uGkTzkk1d0ByLeSc6ZsFS79Axg+m1P/VsgYsxHgiuc3tFSj+MjMIwe90FC4lOAZzNBdY7kkO2P2wKdsQ1vgQ==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.1", "gopd": "^1.0.1", @@ -8764,7 +8770,6 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.2.tgz", "integrity": "sha512-0gSo4ml/0j98Y3lngkFEot/zhiCeWsbYIlZ+uZOVgzLyLaUw7wxUL+nCTP0XJvJg1AXulJRI3UJi8GsbDuxdGA==", - "dev": true, "dependencies": { "function-bind": "^1.1.2", "has-proto": "^1.0.1", @@ -8903,7 +8908,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dev": true, "dependencies": { "get-intrinsic": "^1.1.3" }, @@ -9090,7 +9094,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.1.tgz", "integrity": "sha512-VsX8eaIewvas0xnvinAe9bw4WfIeODpGYikiWYLH+dma0Jw6KHYqWiWfhQlgOVK8D6PvjubK5Uc4P0iIhIcNVg==", - "dev": true, "dependencies": { "get-intrinsic": "^1.2.2" }, @@ -9102,7 +9105,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -9114,7 +9116,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", - "dev": true, "engines": { "node": ">= 0.4" }, @@ -11154,7 +11155,6 @@ "version": "1.13.1", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -12004,6 +12004,20 @@ "node": ">=6.0.0" } }, + "node_modules/qs": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.2.tgz", + "integrity": "sha512-tDNIz22aBzCDxLtVH++VnTfzxlfeK5CbqohpSqpJgj1Wg/cQbStNAz3NuqCs5vV+pjBsK4x4pN9HlVh7rcYRiA==", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -12603,7 +12617,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.1.1.tgz", "integrity": "sha512-VoaqjbBJKiWtg4yRcKBQ7g7wnGnLV3M8oLvVWwOk2PdYY6PEFegR1vezXR0tw6fZGF9csVakIRjrJiy2veSBFQ==", - "dev": true, "dependencies": { "define-data-property": "^1.1.1", "get-intrinsic": "^1.2.1", @@ -12695,7 +12708,6 @@ "version": "1.0.4", "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.4.tgz", "integrity": "sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==", - "dev": true, "dependencies": { "call-bind": "^1.0.0", "get-intrinsic": "^1.0.2", diff --git a/package.json b/package.json index 917ffa1..cbc1705 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "lucide-react": "^0.309.0", "micro": "^9.4.1", "next": "14.0.4", + "qs": "^6.11.2", "react": "^18", "react-day-picker": "^8.10.0", "react-dom": "^18", @@ -58,6 +59,7 @@ "@graphql-codegen/typescript-react-apollo": "^4.1.0", "@types/better-sqlite3": "^7.6.9", "@types/node": "^20", + "@types/qs": "^6.9.11", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", diff --git a/src/components/DateRange.tsx b/src/components/DateRange.tsx index 32f9238..83b48ce 100644 --- a/src/components/DateRange.tsx +++ b/src/components/DateRange.tsx @@ -1,8 +1,6 @@ -import * as React from "react"; -import { addDays, format } from "date-fns"; +import { format } from "date-fns"; import { Calendar as CalendarIcon } from "lucide-react"; -import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; import { @@ -10,6 +8,7 @@ import { PopoverContent, PopoverTrigger, } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; import type { DateRange, SelectRangeEventHandler } from "react-day-picker"; type DateRangeProps = { diff --git a/src/components/PreviewCard.tsx b/src/components/PreviewCard.tsx index e2db426..d86953d 100644 --- a/src/components/PreviewCard.tsx +++ b/src/components/PreviewCard.tsx @@ -17,7 +17,9 @@ export function PreviewCard({ className, glamp, ...props }: CardProps) { return ( - {glamp.title} + + {glamp.title} + {glamp.description} diff --git a/src/components/SeachBar.tsx b/src/components/SeachBar.tsx index 6cc71ac..ad07502 100644 --- a/src/components/SeachBar.tsx +++ b/src/components/SeachBar.tsx @@ -5,6 +5,7 @@ import { z } from "zod"; import { Button } from "@/components/ui/button"; import { Form, + FormControl, FormDescription, FormField, FormItem, @@ -12,21 +13,62 @@ import { FormMessage, } from "@/components/ui/form"; import { toast } from "@/components/ui/use-toast"; +import { searchParamsSchema } from "@/lib/validators/searchParams"; +import { useRouter } from "next/router"; +import { isDateRange } from "react-day-picker"; import { DateRange } from "./DateRange"; +import { Checkbox } from "./ui/checkbox"; const FormSchema = z.object({ dateRange: z.object({ from: z.date({ required_error: "A date of birth is required." }), to: z.date({ required_error: "A date to is required" }), }), + isLuxury: z.boolean().default(false).optional(), }); -function SearchBar() { +type SearchBarProps = { + onSearch: ({ variables, dateRange }) => void; +}; + +function SearchBar({ onSearch }: SearchBarProps) { + const router = useRouter(); + + const searchParamsResult = searchParamsSchema.safeParse(router.query); + + let defaultValues = { + dateRange: { + from: undefined, + to: undefined, + }, + isLuxury: false, + }; + + if (searchParamsResult.success) { + const { dateFrom, dateTo, isLuxury } = searchParamsResult.data; + defaultValues = { + dateRange: { + from: dateFrom ? new Date(dateFrom) : undefined, + to: dateTo ? new Date(dateTo) : undefined, + }, + isLuxury, + }; + } const form = useForm>({ resolver: zodResolver(FormSchema), + defaultValues, }); function onSubmit(data: z.infer) { + onSearch({ + dateRange: data.dateRange, + variables: { + offset: 0, + limit: 3, + isLuxury: data.isLuxury, + }, + }); + toast({ title: "You submitted the following values:", description: ( @@ -44,14 +86,39 @@ function SearchBar() { { + const validDateRange = isDateRange(field.value) + ? field.value + : { from: undefined, to: undefined }; + return ( + + Date of birth + + + Your date of birth is used to calculate your age. + + + + ); + }} + /> + ( - - Date of birth - - - Your date of birth is used to calculate your age. - - + + + + +
+ Luxury Glamping +
)} /> diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts index 1671307..74cd4ab 100644 --- a/src/components/ui/use-toast.ts +++ b/src/components/ui/use-toast.ts @@ -1,76 +1,74 @@ // Inspired by react-hot-toast library -import * as React from "react" +import * as React from "react"; -import type { - ToastActionElement, - ToastProps, -} from "@/components/ui/toast" +import type { ToastActionElement, ToastProps } from "@/components/ui/toast"; -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} + id: string; + title?: React.ReactNode; + description?: React.ReactNode; + action?: ToastActionElement; +}; const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", DISMISS_TOAST: "DISMISS_TOAST", REMOVE_TOAST: "REMOVE_TOAST", -} as const +} as const; -let count = 0 +let count = 0; function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); } -type ActionType = typeof actionTypes +type ActionType = typeof actionTypes; type Action = | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; } | { - type: ActionType["UPDATE_TOAST"] - toast: Partial + type: ActionType["UPDATE_TOAST"]; + toast: Partial; } | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; } | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; interface State { - toasts: ToasterToast[] + toasts: ToasterToast[]; } -const toastTimeouts = new Map>() +const toastTimeouts = new Map>(); const addToRemoveQueue = (toastId: string) => { if (toastTimeouts.has(toastId)) { - return + return; } const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) + // eslint-disable-next-line drizzle/enforce-delete-with-where + toastTimeouts.delete(toastId); dispatch({ type: "REMOVE_TOAST", toastId: toastId, - }) - }, TOAST_REMOVE_DELAY) + }); + }, TOAST_REMOVE_DELAY); - toastTimeouts.set(toastId, timeout) -} + toastTimeouts.set(toastId, timeout); +}; export const reducer = (state: State, action: Action): State => { switch (action.type) { @@ -78,7 +76,7 @@ export const reducer = (state: State, action: Action): State => { return { ...state, toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), - } + }; case "UPDATE_TOAST": return { @@ -86,19 +84,19 @@ export const reducer = (state: State, action: Action): State => { toasts: state.toasts.map((t) => t.id === action.toast.id ? { ...t, ...action.toast } : t ), - } + }; case "DISMISS_TOAST": { - const { toastId } = action + const { toastId } = action; // ! Side effects ! - This could be extracted into a dismissToast() action, // but I'll keep it here for simplicity if (toastId) { - addToRemoveQueue(toastId) + addToRemoveQueue(toastId); } else { state.toasts.forEach((toast) => { - addToRemoveQueue(toast.id) - }) + addToRemoveQueue(toast.id); + }); } return { @@ -111,44 +109,44 @@ export const reducer = (state: State, action: Action): State => { } : t ), - } + }; } case "REMOVE_TOAST": if (action.toastId === undefined) { return { ...state, toasts: [], - } + }; } return { ...state, toasts: state.toasts.filter((t) => t.id !== action.toastId), - } + }; } -} +}; -const listeners: Array<(state: State) => void> = [] +const listeners: Array<(state: State) => void> = []; -let memoryState: State = { toasts: [] } +let memoryState: State = { toasts: [] }; function dispatch(action: Action) { - memoryState = reducer(memoryState, action) + memoryState = reducer(memoryState, action); listeners.forEach((listener) => { - listener(memoryState) - }) + listener(memoryState); + }); } -type Toast = Omit +type Toast = Omit; function toast({ ...props }: Toast) { - const id = genId() + const id = genId(); const update = (props: ToasterToast) => dispatch({ type: "UPDATE_TOAST", toast: { ...props, id }, - }) - const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); dispatch({ type: "ADD_TOAST", @@ -157,36 +155,36 @@ function toast({ ...props }: Toast) { id, open: true, onOpenChange: (open) => { - if (!open) dismiss() + if (!open) dismiss(); }, }, - }) + }); return { id: id, dismiss, update, - } + }; } function useToast() { - const [state, setState] = React.useState(memoryState) + const [state, setState] = React.useState(memoryState); React.useEffect(() => { - listeners.push(setState) + listeners.push(setState); return () => { - const index = listeners.indexOf(setState) + const index = listeners.indexOf(setState); if (index > -1) { - listeners.splice(index, 1) + listeners.splice(index, 1); } - } - }, [state]) + }; + }, [state]); return { ...state, toast, dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), - } + }; } -export { useToast, toast } +export { useToast, toast }; diff --git a/src/graphql/schema/glamps/glamps.resolver.ts b/src/graphql/schema/glamps/glamps.resolver.ts index 6b6cb99..d59222a 100644 --- a/src/graphql/schema/glamps/glamps.resolver.ts +++ b/src/graphql/schema/glamps/glamps.resolver.ts @@ -66,7 +66,7 @@ export class GlampResolver { ); } - if (isLuxury !== undefined) { + if (isLuxury === true) { glampsQueryBuilder = glampsQueryBuilder.where( eq(dbGlamps.isLuxury, isLuxury) ); diff --git a/src/lib/validators/searchParams.ts b/src/lib/validators/searchParams.ts new file mode 100644 index 0000000..73081cf --- /dev/null +++ b/src/lib/validators/searchParams.ts @@ -0,0 +1,21 @@ +import { ParsedUrlQuery } from "querystring"; +import { z } from "zod"; + +export const searchParamsSchema = z.object({ + dateFrom: z.string().datetime({ offset: true }), + dateTo: z.string().datetime({ offset: true }), + isLuxury: z.coerce.boolean(), +}); + +export const getValidSearchParams = (query: ParsedUrlQuery) => { + const parsedResult = searchParamsSchema.safeParse(query); + if (parsedResult.success) { + return parsedResult.data; + } else { + return { + dateFrom: undefined, + dateTo: undefined, + isLuxury: false, + }; + } +}; diff --git a/src/pages/glamps/index.tsx b/src/pages/glamps/index.tsx index d03ec05..60652bb 100644 --- a/src/pages/glamps/index.tsx +++ b/src/pages/glamps/index.tsx @@ -1,29 +1,56 @@ -import ExampleExplanation from "@/components/ExampleExplanation"; import { PreviewCard } from "@/components/PreviewCard"; import SearchBar from "@/components/SeachBar"; import { Shell } from "@/components/Shell"; -import { Button } from "@/components/ui/button"; import { - GetDealsOffsetBasedDocument, - useGetDealsOffsetBasedQuery, + GetSearchGlampsDocument, + GetSearchGlampsQuery, + GetSearchGlampsQueryVariables, useGetSearchGlampsQuery, } from "@/generated/graphql"; +import { getValidSearchParams } from "@/lib/validators/searchParams"; import { addApolloState, initializeApollo } from "@/utils/apolloClient"; - -const EXPLANATION = { - title: "Offset Pagination - single field query", - description: - "Nejjednodušší způsob offset-based paginace, pro sprváný cachování je potřeba nastavit typePolicy v InMemoryCache.", -}; +import { formatISO } from "date-fns"; +import { GetServerSidePropsContext } from "next"; +import { useRouter } from "next/router"; +import { DateRange } from "react-day-picker"; export default function GlampsSearchPage() { - const { data, loading } = useGetSearchGlampsQuery({ + const router = useRouter(); + + const { isLuxury } = router.query; + + const { data, loading, refetch } = useGetSearchGlampsQuery({ variables: { limit: 3, offset: 0, + isLuxury: !!isLuxury, }, + fetchPolicy: "cache-first", + nextFetchPolicy: "network-only", }); + const handleSearch = async ({ + variables, + dateRange, + }: { + variables: GetSearchGlampsQueryVariables; + dateRange: DateRange; + }) => { + const searchParams = { + isLuxury: variables.isLuxury ?? false, + dateFrom: formatISO(dateRange.from) ?? undefined, + dateTo: formatISO(dateRange.to) ?? undefined, + }; + + // Bez await je zkreslený middleware a dostává pozdě datumy + await router.push({ + pathname: router.pathname, + query: { ...searchParams }, + }); + + refetch(variables); + }; + if (loading) {
loading
; } @@ -31,7 +58,7 @@ export default function GlampsSearchPage() { return (

Glamping

- +
{data?.searchGlamps.glamps.map((glamp) => ( @@ -41,19 +68,23 @@ export default function GlampsSearchPage() { ); } -/* export async function getServerSideProps() { - const apolloClient = initializeApollo({}); +export async function getServerSideProps(context: GetServerSidePropsContext) { + const apolloClient = initializeApollo({ context }); - await apolloClient.query({ - query: GetDealsOffsetBasedDocument, - variables: { - limit: 3, - offset: 0, - }, - }); + const validSearchParams = getValidSearchParams(context.query); + + await apolloClient.query( + { + query: GetSearchGlampsDocument, + variables: { + limit: 3, + offset: 0, + isLuxury: validSearchParams.isLuxury, + }, + } + ); return addApolloState(apolloClient, { props: {}, }); } - */ diff --git a/src/utils/apolloClient.ts b/src/utils/apolloClient.ts index bb214e1..3364d0c 100644 --- a/src/utils/apolloClient.ts +++ b/src/utils/apolloClient.ts @@ -12,6 +12,7 @@ import merge from "deepmerge"; import isEqual from "lodash/isEqual"; import { GetServerSidePropsContext } from "next"; import { useMemo } from "react"; +import qs from "qs"; const APOLLO_STATE_PROP_NAME = "__APOLLO_STATE__"; @@ -33,11 +34,17 @@ const httpLink = new HttpLink({ }); function createApolloClient(context?: GetServerSidePropsContext) { + const searchParams = + typeof window !== "undefined" + ? window.location.search.substring(1) + : qs.stringify(context?.query); + const languageLink = createLanguageMiddleware(context?.locale ?? "cs"); - console.log(context?.locale); + const dateRangeLink = createDateRangeMiddleware(searchParams); + return new ApolloClient({ ssrMode: typeof window === "undefined", - link: from([errorLink, languageLink, httpLink]), + link: from([errorLink, languageLink, dateRangeLink, httpLink]), connectToDevTools: true, cache: new InMemoryCache({ typePolicies: { @@ -176,3 +183,31 @@ function createLanguageMiddleware(language: string) { return forward(operation); }); } + +function createDateRangeMiddleware(searchParams: string) { + return new ApolloLink((operation, forward) => { + const currentSearchParams = + typeof window !== "undefined" + ? window.location.search.substring(1) + : searchParams; + + const params = new URLSearchParams(currentSearchParams); + + const dateFrom = params.get("dateFrom"); + const dateTo = params.get("dateTo"); + + const dateRange = JSON.stringify({ + from: dateFrom, + to: dateTo, + }); + + operation.setContext(({ headers = {} }) => ({ + headers: { + ...headers, + "x-date-range": dateRange, + }, + })); + + return forward(operation); + }); +}