diff --git a/apps/app-router-example/app/funnel.tsx b/apps/app-router-example/app/funnel.tsx index 3310c2f..5f70124 100644 --- a/apps/app-router-example/app/funnel.tsx +++ b/apps/app-router-example/app/funnel.tsx @@ -1,23 +1,11 @@ "use client"; import { useFunnel } from "@xionhub/funnel-app-router-adapter"; -import { funnelOptions } from "@xionhub/funnel-core"; import { overlay } from "overlay-kit"; -import { useEffect } from "react"; - -const EXAMPLE_FUNNEL_ID = "hello-this-is-funnel-id"; -const exampleFunnelOptions = funnelOptions({ - steps: ["a", "b", "c"], - funnelId: EXAMPLE_FUNNEL_ID, -}); +import { exampleFunnelOptions } from "~/src/example-funnel"; export default function ExampleFunnel() { - const [Funnel, controller] = useFunnel(exampleFunnelOptions); - - // biome-ignore lint/correctness/useExhaustiveDependencies: - useEffect(() => { - controller.onStepChange("a"); - }, []); + const [Funnel, controller] = useFunnel(exampleFunnelOptions()); return (
@@ -35,7 +23,7 @@ export default function ExampleFunnel() { await new Promise((res) => setTimeout(res, 1000)); return false; }} - onFunnelRestrictEvent={async () => { + onRestrict={async () => { await overlay.openAsync(({ close, unmount }) => (
접근할 수 없는 상태에요
@@ -43,6 +31,7 @@ export default function ExampleFunnel() { type="button" onClick={() => { close(true); + unmount(); }} > 처음 화면으로 돌아가기 diff --git a/apps/app-router-example/app/funnel/page.tsx b/apps/app-router-example/app/funnel/page.tsx new file mode 100644 index 0000000..c8dacac --- /dev/null +++ b/apps/app-router-example/app/funnel/page.tsx @@ -0,0 +1,12 @@ +import { Suspense } from "react"; +import ExampleFunnel from "../funnel"; + +export default function Page() { + return ( +
+ + + +
+ ); +} diff --git a/apps/app-router-example/app/page.tsx b/apps/app-router-example/app/page.tsx index e07d29e..663f7e4 100644 --- a/apps/app-router-example/app/page.tsx +++ b/apps/app-router-example/app/page.tsx @@ -1,12 +1,13 @@ -import { Suspense } from "react"; -import ExampleFunnel from "./funnel"; +"use client"; +import { FunnelClient } from "@xionhub/funnel-core"; +import Link from "next/link"; +import { exampleFunnelOptions } from "~/src/example-funnel"; export default function Home() { + const funnelClient = new FunnelClient(exampleFunnelOptions()); return (
- - - + 퍼널로 이동하기
); } diff --git a/apps/app-router-example/src/example-funnel.ts b/apps/app-router-example/src/example-funnel.ts new file mode 100644 index 0000000..37a93a2 --- /dev/null +++ b/apps/app-router-example/src/example-funnel.ts @@ -0,0 +1,8 @@ +import { FunnelClient, funnelOptions } from "@xionhub/funnel-core"; + +const EXAMPLE_FUNNEL_ID = "hello-this-is-funnel-id"; +export const exampleFunnelOptions = () => + funnelOptions({ + steps: ["a", "b", "c"] as const, + funnelId: EXAMPLE_FUNNEL_ID, + }); diff --git a/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx b/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx index 046dfb0..a8259be 100644 --- a/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx +++ b/packages/funnel-app-router-adapter/src/external/use-funnel-app-router-adapter.tsx @@ -1,31 +1,42 @@ import { - DEFAULT_FUNNEL_STEP_ID, - type FunnelAdapterReturnType, + type CreateFunnelStepFunction, + FunnelClient, type FunnelOptions, + type FunnelStepChangeFunction, type NonEmptyArray, - funnelQs, useCoreFunnel, } from "@xionhub/funnel-core"; -import { useRouter, useSearchParams } from "next/navigation"; +import { usePathname, useRouter, useSearchParams } from "next/navigation"; export const useFunnelAppRouterAdapter = >( options: Omit, "step">, -): FunnelAdapterReturnType => { - const funnelId = options?.funnelId ?? DEFAULT_FUNNEL_STEP_ID; +) => { + const funnelId = options.funnelId; const router = useRouter(); - - const queryStep = useSearchParams().get(funnelId); + const searchParams = useSearchParams(); + const queryStep = searchParams.get(funnelId); + const funnelClient = new FunnelClient(options); + const pathname = usePathname(); const step = (queryStep ?? undefined) as Steps[number] | undefined; const [Funnel, controller] = useCoreFunnel({ ...options, step }); - const onStepChange: FunnelAdapterReturnType["1"]["onStepChange"] = (newStep, options) => { + const createFunnelStep: CreateFunnelStepFunction = (step, options) => { + if (!options?.searchParams) { + return { [funnelId]: step }; + } + const allQueryString = funnelClient.getQueryString(options?.searchParams); + const deleteKeyList = Array.isArray(options?.deleteQueryParams) ? options?.deleteQueryParams : ([options?.deleteQueryParams].filter(Boolean) as string[]); - const value = funnelQs.updateFunnelQs({ [funnelId]: newStep }, deleteKeyList); + const stepObject = funnelClient.createStepObject(step, funnelClient.deleteStep(allQueryString, deleteKeyList)); + return stepObject; + }; - const newUrl = `${funnelQs.getPathName()}${value}`; + const onStepChange: FunnelStepChangeFunction = (newStep, options) => { + const stepObject = createFunnelStep(newStep, { ...options, searchParams }); + const newUrl = `${pathname}${funnelClient.stringifyStep(stepObject)}`; if (options?.type === "replace") { return router.replace(newUrl); @@ -38,5 +49,5 @@ export const useFunnelAppRouterAdapter = >( return router.push(newUrl); }; - return [Funnel, { ...controller, onStepChange }] as const; + return [Funnel, { ...controller, onStepChange, createFunnelStep }] as const; }; diff --git a/packages/funnel-core/src/external/constant.ts b/packages/funnel-core/src/external/constant.ts deleted file mode 100644 index d350a8a..0000000 --- a/packages/funnel-core/src/external/constant.ts +++ /dev/null @@ -1 +0,0 @@ -export const DEFAULT_FUNNEL_STEP_ID = "step"; diff --git a/packages/funnel-core/src/external/external-utils.ts b/packages/funnel-core/src/external/external-utils.ts index 8b862f6..c2bfa6d 100644 --- a/packages/funnel-core/src/external/external-utils.ts +++ b/packages/funnel-core/src/external/external-utils.ts @@ -1,4 +1,4 @@ -import { DEFAULT_FUNNEL_STEP_ID } from "./constant"; +import qs from "qs"; import type { FunnelOptions, NonEmptyArray } from "./types"; export const funnelOptions = >(props: FunnelOptions): FunnelOptions => props; @@ -8,12 +8,42 @@ export class FunnelClient> { steps: T; constructor(props: FunnelOptions) { - this.funnelId = props.funnelId ?? DEFAULT_FUNNEL_STEP_ID; + this.funnelId = props.funnelId; this.steps = props.steps; } - createFunnelStep(value: T[number]) { - const funnelId = this.funnelId ?? DEFAULT_FUNNEL_STEP_ID; - return `${funnelId}=${value}` as const; + createStep(value: T[number], context?: Record) { + return this.stringifyStep(this.createStepObject(value, context)); + } + + getQueryString>(searchParams: URLSearchParams) { + const result = {} as Record; + searchParams.forEach((value, key) => { + result[key] = value; + }); + return result as T; + } + + createStepObject(value: T[number], context?: Record) { + return { ...context, [this.funnelId]: value } as const; + } + + // biome-ignore lint/suspicious/noExplicitAny: + deleteStep, K extends keyof T>(obj: T, keys: K[]): Omit { + const result = { ...obj }; + + for (const key of keys) { + delete result[key]; + } + + return result as Omit; + } + + stringifyStep(context: Record) { + return qs.stringify(context, { addQueryPrefix: true }); + } + + parseQueryString(queryString: string) { + return qs.parse(queryString, { ignoreQueryPrefix: true }) as T; } } diff --git a/packages/funnel-core/src/external/guard.tsx b/packages/funnel-core/src/external/guard.tsx index c57438b..31acb45 100644 --- a/packages/funnel-core/src/external/guard.tsx +++ b/packages/funnel-core/src/external/guard.tsx @@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from "react"; import type { GuardProps } from "./types"; -export const Guard = ({ condition, children, fallback, onFunnelRestrictEvent }: GuardProps) => { +export const Guard = ({ condition, children, fallback, onRestrict }: GuardProps) => { const [isRender, setIsRender] = useState(false); const isOnce = useRef(true); const canImmediateRender = @@ -19,7 +19,7 @@ export const Guard = ({ condition, children, fallback, onFunnelRestrictEvent }: if (typeof condition === "function") { result = await condition(); if (result === false) { - onFunnelRestrictEvent?.(); + onRestrict?.(); } else { setIsRender(true); } @@ -27,7 +27,7 @@ export const Guard = ({ condition, children, fallback, onFunnelRestrictEvent }: if (typeof condition === "boolean") { if (condition === false) { - onFunnelRestrictEvent?.(); + onRestrict?.(); } else { setIsRender(true); } @@ -38,7 +38,7 @@ export const Guard = ({ condition, children, fallback, onFunnelRestrictEvent }: check(); isOnce.current = false; } - }, [canImmediateRender, condition, onFunnelRestrictEvent]); + }, [canImmediateRender, condition, onRestrict]); return canImmediateRender || isRender ? children : fallback; }; diff --git a/packages/funnel-core/src/external/query-string.ts b/packages/funnel-core/src/external/query-string.ts deleted file mode 100644 index 3ce94c9..0000000 --- a/packages/funnel-core/src/external/query-string.ts +++ /dev/null @@ -1,63 +0,0 @@ -import qs from "qs"; - -const getOrigin = () => { - if (typeof window === "undefined") return ""; - return window.location.origin; -}; - -const getPathName = () => { - if (typeof window === "undefined") return ""; - return window.location.pathname; -}; - -const getQs = >(): T => { - if (typeof window === "undefined") return {} as T; - - const search = window.location.search; - return qs.parse(search, { ignoreQueryPrefix: true }) as T; -}; - -const updateQs = >(urlParams: T, params: Partial): T => { - const currentParams = { ...urlParams }; - - // biome-ignore lint/complexity/noForEach: - Object.keys(params).forEach((key) => { - //@ts-ignore - currentParams[key] = params[key]; - }); - - return currentParams; -}; - -// 특정 쿼리스트링을 삭제하는 함수 -const deleteQs = , U extends string>(urlParams: T, keys: U[]): Omit => { - const currentParams = { ...urlParams }; - - // biome-ignore lint/complexity/noForEach: - keys.forEach((key) => { - delete currentParams[key]; - }); - - return currentParams; -}; - -const stringifyQs = (params: Record): string => { - return qs.stringify(params, { addQueryPrefix: true }); -}; - -const updateFunnelQs = (param: Record, deleteKey?: string[]): string => { - const currentQueryParam = funnelQs.getQs(); - const updateQueryParam = funnelQs.updateQs(currentQueryParam, param); - const deleteQueryParam = funnelQs.deleteQs(updateQueryParam, deleteKey ?? []); - return funnelQs.stringifyQs(deleteQueryParam); -}; - -export const funnelQs = { - getQs, - updateQs, - deleteQs, - stringifyQs, - getOrigin, - getPathName, - updateFunnelQs, -}; diff --git a/packages/funnel-core/src/external/types.ts b/packages/funnel-core/src/external/types.ts index fd29f24..759bdec 100644 --- a/packages/funnel-core/src/external/types.ts +++ b/packages/funnel-core/src/external/types.ts @@ -4,9 +4,11 @@ export type NonEmptyArray = readonly [T, ...T[]]; export type RoutesEventType = "replace" | "push" | "back"; +export type DeleteQueryParams = { deleteQueryParams?: string[] | string }; + export type FunnelStepChangeFunction> = ( step: T[number], - options?: { type?: RoutesEventType; deleteQueryParams?: string[] | string }, + options?: { type?: RoutesEventType } & DeleteQueryParams, ) => void; export type RouteFunnelProps> = Omit, "steps" | "step">; @@ -25,10 +27,15 @@ export interface StepProps> { export interface GuardProps { condition: boolean | (() => boolean | Promise); children?: ReactNode; - onFunnelRestrictEvent?: () => void; + onRestrict?: () => void; fallback?: ReactNode; } +export type CreateFunnelStepFunction> = ( + step: Steps[number], + options?: { deleteQueryParams?: string[] | string; searchParams?: URLSearchParams }, +) => Record; + export type FunnelAdapterReturnType> = [ ((props: RouteFunnelProps) => JSX.Element) & { Step: (props: StepProps) => JSX.Element; @@ -38,11 +45,12 @@ export type FunnelAdapterReturnType> = [ funnelId: string; step: Steps[number] | undefined; onStepChange: FunnelStepChangeFunction; + createFunnelStep: CreateFunnelStepFunction; }, ]; export type FunnelOptions> = { steps: T; step?: T[number] | undefined; - funnelId?: string; + funnelId: string; }; diff --git a/packages/funnel-core/src/external/use-core-funnel.tsx b/packages/funnel-core/src/external/use-core-funnel.tsx index 135165b..7d63193 100644 --- a/packages/funnel-core/src/external/use-core-funnel.tsx +++ b/packages/funnel-core/src/external/use-core-funnel.tsx @@ -1,21 +1,31 @@ -import { useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { useDraft } from "../internal/use-draft"; -import { DEFAULT_FUNNEL_STEP_ID } from "./constant"; import { Funnel } from "./funnel"; import { Guard } from "./guard"; import { Step } from "./step"; -import type { FunnelOptions, GuardProps, NonEmptyArray, RouteFunnelProps, StepProps } from "./types"; +import type { + FunnelOptions, + FunnelStepChangeFunction, + GuardProps, + NonEmptyArray, + RouteFunnelProps, + StepProps, +} from "./types"; -export const useCoreFunnel = >(options: FunnelOptions) => { - const [_step, _setStep] = useDraft(options?.step ?? options?.steps[0]); +export const useCoreFunnel = >( + options: FunnelOptions & { onStepChange?: FunnelStepChangeFunction }, +) => { + const [_step, _setStep] = useDraft(options?.step); const steps = options.steps; const step = options?.step; - const funnelId = options?.funnelId ?? DEFAULT_FUNNEL_STEP_ID; + const funnelId = options?.funnelId; - const _onStepChange = (param: Steps[number]) => { + const _onStepChange: FunnelStepChangeFunction = (param: Steps[number], routeOptions) => { _setStep(param); }; + const onStepChange = options?.onStepChange ?? _onStepChange; + const FunnelComponent = useMemo(() => { return Object.assign( (props: RouteFunnelProps) => { @@ -32,5 +42,5 @@ export const useCoreFunnel = >(options: Funn ); }, [step, steps]); - return [FunnelComponent, { funnelId, step, onStepChange: _onStepChange }] as const; + return [FunnelComponent, { funnelId, step, onStepChange }] as const; }; diff --git a/packages/funnel-core/src/index.ts b/packages/funnel-core/src/index.ts index d99d637..8dc0875 100644 --- a/packages/funnel-core/src/index.ts +++ b/packages/funnel-core/src/index.ts @@ -1,8 +1,7 @@ -import { DEFAULT_FUNNEL_STEP_ID } from "./external/constant"; import { FunnelClient, funnelOptions } from "./external/external-utils"; import { Guard as FunnelGuard } from "./external/guard"; -import { funnelQs } from "./external/query-string"; import type { + CreateFunnelStepFunction, FunnelAdapterReturnType, FunnelOptions, FunnelProps, @@ -15,7 +14,7 @@ import type { } from "./external/types"; import { useCoreFunnel } from "./external/use-core-funnel"; -export { useCoreFunnel, FunnelGuard, funnelQs, funnelOptions, FunnelClient, DEFAULT_FUNNEL_STEP_ID }; +export { useCoreFunnel, FunnelGuard, funnelOptions, FunnelClient }; export type { NonEmptyArray, @@ -27,4 +26,5 @@ export type { GuardProps, FunnelAdapterReturnType, FunnelOptions, + CreateFunnelStepFunction, };