Browse Source
refactor: implement entry route caching and improve loading skeletons for profile redirection.
Dev
refactor: implement entry route caching and improve loading skeletons for profile redirection.
Dev
38 changed files with 884 additions and 136 deletions
-
2public/assets/images/Ellipse 1210.svg
-
2public/assets/images/Group 1597880466.svg
-
2public/assets/images/Group 15978804fdasf68.svg
-
2public/assets/images/Group 159788fd0467.svg
-
2public/assets/images/Group 15978fdsa80467.svg
-
2public/assets/images/female_avatar.svg
-
2public/assets/images/home-Checkups-List.svg
-
2public/assets/images/islamic_pattern_2_2892_3864.svg
-
32src/app/[lang]/page.tsx
-
9src/app/api/proxy/route.ts
-
41src/app/globals.css
-
10src/app/intro/page.tsx
-
41src/app/layout.tsx
-
24src/app/page.tsx
-
53src/app/questions-list/page.tsx
-
17src/app/terms/page.test.tsx
-
16src/app/terms/page.tsx
-
134src/components/Componentes/entry-route-resolver.test.tsx
-
103src/components/Componentes/entry-route-resolver.tsx
-
8src/components/Componentes/female-outcome-sheet.test.tsx
-
23src/components/Componentes/loading-skeleton.tsx
-
84src/components/Componentes/network-image.tsx
-
8src/components/Componentes/question-answer.test.tsx
-
7src/components/Componentes/schema-question-flow.integration.test.tsx
-
63src/components/Componentes/slider-page.test.tsx
-
26src/components/Componentes/slider-page.tsx
-
11src/components/Componentes/slider-slide-two.tsx
-
6src/components/Componentes/token-switcher.tsx
-
2src/hooks/marriage/use-match-start.ts
-
48src/hooks/marriage/use-profile-main.test.ts
-
9src/hooks/marriage/use-profile-main.ts
-
39src/lib/auth-bridge.test.ts
-
26src/lib/auth-bridge.ts
-
46src/lib/entry-route-cache.test.ts
-
65src/lib/entry-route-cache.ts
-
20src/lib/get-submit-path.test.ts
-
19src/lib/get-submit-path.ts
-
14src/lib/utils.ts
2
public/assets/images/Ellipse 1210.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
public/assets/images/Group 1597880466.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
public/assets/images/Group 15978804fdasf68.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
public/assets/images/Group 159788fd0467.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
public/assets/images/Group 15978fdsa80467.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
public/assets/images/female_avatar.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
public/assets/images/home-Checkups-List.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
2
public/assets/images/islamic_pattern_2_2892_3864.svg
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -1 +1,31 @@ |
|||
export { default } from "@/app/intro/page"; |
|||
import { cookies } from "next/headers"; |
|||
import { redirect } from "next/navigation"; |
|||
import EntryRouteResolver from "@/components/Componentes/entry-route-resolver"; |
|||
import { |
|||
getAuthenticatedCachedEntryPath, |
|||
MARRIAGE_ENTRY_PATH_COOKIE, |
|||
} from "@/lib/entry-route-cache"; |
|||
import { localizePath } from "@/translations/config"; |
|||
|
|||
export const dynamic = "force-dynamic"; |
|||
|
|||
export default async function LocaleEntryPage({ |
|||
params, |
|||
}: { |
|||
params: Promise<{ lang: string }>; |
|||
}) { |
|||
const [{ lang }, cookieStore] = await Promise.all([params, cookies()]); |
|||
const token = |
|||
cookieStore.get("HABIB_TOKEN")?.value ?? |
|||
cookieStore.get("habib_token")?.value; |
|||
const cachedEntryPath = getAuthenticatedCachedEntryPath( |
|||
token, |
|||
cookieStore.get(MARRIAGE_ENTRY_PATH_COOKIE)?.value, |
|||
); |
|||
|
|||
if (cachedEntryPath) { |
|||
redirect(localizePath(cachedEntryPath, lang)); |
|||
} |
|||
|
|||
return <EntryRouteResolver />; |
|||
} |
|||
@ -0,0 +1,134 @@ |
|||
import { render, waitFor } from "@testing-library/react"; |
|||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import EntryRouteResolver from "./entry-route-resolver"; |
|||
|
|||
const mocks = vi.hoisted(() => ({ |
|||
getCachedEntryPath: vi.fn(), |
|||
isAuthenticated: vi.fn(), |
|||
refetchProfile: vi.fn(), |
|||
replace: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("next/navigation", () => ({ |
|||
useRouter: () => ({ replace: mocks.replace }), |
|||
})); |
|||
|
|||
vi.mock("@/lib/auth-bridge", () => ({ |
|||
HABIB_AUTH_TOKEN_CHANGED_EVENT: "habib:auth-token-changed", |
|||
authBridge: { isAuthenticated: mocks.isAuthenticated }, |
|||
})); |
|||
|
|||
vi.mock("@/lib/entry-route-cache", () => ({ |
|||
getCachedMarriageEntryPath: mocks.getCachedEntryPath, |
|||
})); |
|||
|
|||
vi.mock("@/hooks/marriage/use-profile-main", () => ({ |
|||
useMarriageProfileQuery: () => ({ refetch: mocks.refetchProfile }), |
|||
})); |
|||
|
|||
vi.mock("@/translations/provider", () => ({ |
|||
useI18n: () => ({ locale: "en" }), |
|||
})); |
|||
|
|||
vi.mock("@/translations/config", () => ({ |
|||
localizePath: (path: string, locale: string) => `/${locale}${path}`, |
|||
})); |
|||
|
|||
describe("EntryRouteResolver", () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
mocks.getCachedEntryPath.mockReturnValue(null); |
|||
delete window.HabibApp; |
|||
}); |
|||
|
|||
it("renders no loading or page content while resolving", () => { |
|||
mocks.isAuthenticated.mockReturnValue(true); |
|||
mocks.refetchProfile.mockReturnValue(new Promise(() => {})); |
|||
|
|||
const { container } = render(<EntryRouteResolver />); |
|||
|
|||
expect(container).toBeEmptyDOMElement(); |
|||
}); |
|||
|
|||
it("routes a registered pending-info user directly to questions", async () => { |
|||
mocks.isAuthenticated.mockReturnValue(true); |
|||
mocks.refetchProfile.mockResolvedValue({ |
|||
data: { |
|||
status: "pending_info", |
|||
gender: "female", |
|||
is_registering_for_self: true, |
|||
}, |
|||
}); |
|||
|
|||
render(<EntryRouteResolver />); |
|||
|
|||
await waitFor(() => { |
|||
expect(mocks.replace).toHaveBeenCalledWith("/en/questions-list"); |
|||
}); |
|||
}); |
|||
|
|||
it("uses the persistent entry path without refetching the profile", async () => { |
|||
mocks.isAuthenticated.mockReturnValue(true); |
|||
mocks.getCachedEntryPath.mockReturnValue("/questions-list"); |
|||
|
|||
render(<EntryRouteResolver />); |
|||
|
|||
await waitFor(() => { |
|||
expect(mocks.replace).toHaveBeenCalledWith("/en/questions-list"); |
|||
}); |
|||
expect(mocks.refetchProfile).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("routes a registered user with an advanced status to that status page", async () => { |
|||
mocks.isAuthenticated.mockReturnValue(true); |
|||
mocks.refetchProfile.mockResolvedValue({ |
|||
data: { |
|||
status: "waiting", |
|||
gender: "male", |
|||
is_registering_for_self: false, |
|||
}, |
|||
}); |
|||
|
|||
render(<EntryRouteResolver />); |
|||
|
|||
await waitFor(() => { |
|||
expect(mocks.replace).toHaveBeenCalledWith("/en/finding-match"); |
|||
}); |
|||
}); |
|||
|
|||
it("routes an unauthenticated browser user to the real intro route", async () => { |
|||
mocks.isAuthenticated.mockReturnValue(false); |
|||
|
|||
render(<EntryRouteResolver />); |
|||
|
|||
await waitFor(() => { |
|||
expect(mocks.replace).toHaveBeenCalledWith("/en/intro"); |
|||
}); |
|||
expect(mocks.refetchProfile).not.toHaveBeenCalled(); |
|||
}); |
|||
|
|||
it("waits for the WebView token event without rendering a loading page", async () => { |
|||
let authenticated = false; |
|||
mocks.isAuthenticated.mockImplementation(() => authenticated); |
|||
mocks.refetchProfile.mockResolvedValue({ |
|||
data: { |
|||
status: "pending_info", |
|||
gender: "female", |
|||
is_registering_for_self: true, |
|||
}, |
|||
}); |
|||
window.HabibApp = { postMessage: vi.fn() }; |
|||
|
|||
const { container } = render(<EntryRouteResolver />); |
|||
|
|||
expect(container).toBeEmptyDOMElement(); |
|||
expect(mocks.replace).not.toHaveBeenCalled(); |
|||
|
|||
authenticated = true; |
|||
window.dispatchEvent(new Event("habib:auth-token-changed")); |
|||
|
|||
await waitFor(() => { |
|||
expect(mocks.replace).toHaveBeenCalledWith("/en/questions-list"); |
|||
}); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,103 @@ |
|||
"use client"; |
|||
|
|||
import { useRouter } from "next/navigation"; |
|||
import { useEffect, useRef } from "react"; |
|||
import { useMarriageProfileQuery } from "@/hooks/marriage/use-profile-main"; |
|||
import { |
|||
authBridge, |
|||
HABIB_AUTH_TOKEN_CHANGED_EVENT, |
|||
} from "@/lib/auth-bridge"; |
|||
import { getCachedMarriageEntryPath } from "@/lib/entry-route-cache"; |
|||
import { |
|||
getSubmitPath, |
|||
hasCompletedMarriageProfileBasics, |
|||
} from "@/lib/get-submit-path"; |
|||
import { localizePath } from "@/translations/config"; |
|||
import { useI18n } from "@/translations/provider"; |
|||
|
|||
export default function EntryRouteResolver() { |
|||
const router = useRouter(); |
|||
const { locale } = useI18n(); |
|||
const { refetch } = useMarriageProfileQuery({ |
|||
enabled: false, |
|||
retry: false, |
|||
}); |
|||
const entryCheckIdRef = useRef(0); |
|||
|
|||
useEffect(() => { |
|||
let isActive = true; |
|||
let bridgeWaitTimeout: number | undefined; |
|||
|
|||
const goToIntro = () => { |
|||
router.replace(localizePath("/intro", locale)); |
|||
}; |
|||
|
|||
const resolveEntryRoute = async () => { |
|||
if (!authBridge.isAuthenticated()) { |
|||
if (isActive) { |
|||
goToIntro(); |
|||
} |
|||
return; |
|||
} |
|||
|
|||
const cachedEntryPath = getCachedMarriageEntryPath(); |
|||
if (cachedEntryPath) { |
|||
router.replace(localizePath(cachedEntryPath, locale)); |
|||
return; |
|||
} |
|||
|
|||
const checkId = ++entryCheckIdRef.current; |
|||
|
|||
try { |
|||
const { data: profile } = await refetch(); |
|||
|
|||
if (!isActive || checkId !== entryCheckIdRef.current) { |
|||
return; |
|||
} |
|||
|
|||
if (!hasCompletedMarriageProfileBasics(profile)) { |
|||
goToIntro(); |
|||
return; |
|||
} |
|||
|
|||
router.replace(localizePath(getSubmitPath(profile), locale)); |
|||
} catch (error) { |
|||
console.warn("Could not resolve entry route", error); |
|||
if (isActive && checkId === entryCheckIdRef.current) { |
|||
goToIntro(); |
|||
} |
|||
} |
|||
}; |
|||
|
|||
const handleTokenChanged = () => { |
|||
if (bridgeWaitTimeout !== undefined) { |
|||
window.clearTimeout(bridgeWaitTimeout); |
|||
bridgeWaitTimeout = undefined; |
|||
} |
|||
void resolveEntryRoute(); |
|||
}; |
|||
|
|||
window.addEventListener(HABIB_AUTH_TOKEN_CHANGED_EVENT, handleTokenChanged); |
|||
|
|||
if (authBridge.isAuthenticated()) { |
|||
void resolveEntryRoute(); |
|||
} else if (window.HabibApp) { |
|||
bridgeWaitTimeout = window.setTimeout(goToIntro, 4_000); |
|||
} else { |
|||
goToIntro(); |
|||
} |
|||
|
|||
return () => { |
|||
isActive = false; |
|||
if (bridgeWaitTimeout !== undefined) { |
|||
window.clearTimeout(bridgeWaitTimeout); |
|||
} |
|||
window.removeEventListener( |
|||
HABIB_AUTH_TOKEN_CHANGED_EVENT, |
|||
handleTokenChanged, |
|||
); |
|||
}; |
|||
}, [locale, refetch, router]); |
|||
|
|||
return null; |
|||
} |
|||
@ -0,0 +1,84 @@ |
|||
"use client"; |
|||
|
|||
import Image, { type ImageProps } from "next/image"; |
|||
import { useState, useEffect } from "react"; |
|||
import { cn } from "@/lib/utils"; |
|||
|
|||
export interface NetworkImageProps extends Omit<ImageProps, "src"> { |
|||
src?: string | null; |
|||
fallbackSrc?: string; |
|||
containerClassName?: string; |
|||
placeholderClassName?: string; |
|||
} |
|||
|
|||
export function NetworkImage({ |
|||
src, |
|||
fallbackSrc = "/assets/images/Frame 2095586523.png", |
|||
alt = "", |
|||
className, |
|||
containerClassName, |
|||
placeholderClassName, |
|||
fill, |
|||
width, |
|||
height, |
|||
priority, |
|||
...props |
|||
}: NetworkImageProps) { |
|||
const initialSrc = src || fallbackSrc; |
|||
const [imgSrc, setImgSrc] = useState<string>(initialSrc); |
|||
const [isLoaded, setIsLoaded] = useState(false); |
|||
const [hasError, setHasError] = useState(false); |
|||
|
|||
useEffect(() => { |
|||
const target = src || fallbackSrc; |
|||
setImgSrc(target); |
|||
setHasError(false); |
|||
setIsLoaded(false); |
|||
}, [src, fallbackSrc]); |
|||
|
|||
return ( |
|||
<div |
|||
className={cn( |
|||
fill |
|||
? "absolute inset-0 size-full overflow-hidden" |
|||
: "relative overflow-hidden inline-block", |
|||
containerClassName, |
|||
)} |
|||
style={!fill && width && height ? { width, height } : undefined} |
|||
> |
|||
{!isLoaded && !hasError && ( |
|||
<div |
|||
className={cn( |
|||
"absolute inset-0 animate-pulse bg-slate-200/60 dark:bg-white/[0.08] z-0", |
|||
placeholderClassName, |
|||
)} |
|||
aria-hidden |
|||
/> |
|||
)} |
|||
<Image |
|||
{...props} |
|||
src={imgSrc} |
|||
alt={alt} |
|||
fill={fill} |
|||
width={!fill ? width : undefined} |
|||
height={!fill ? height : undefined} |
|||
priority={priority} |
|||
onLoad={() => setIsLoaded(true)} |
|||
onError={() => { |
|||
if (imgSrc !== fallbackSrc) { |
|||
setImgSrc(fallbackSrc); |
|||
} else { |
|||
setHasError(true); |
|||
} |
|||
}} |
|||
className={cn( |
|||
"transition-opacity duration-300", |
|||
!isLoaded ? "opacity-0" : "opacity-100", |
|||
className, |
|||
)} |
|||
/> |
|||
</div> |
|||
); |
|||
} |
|||
|
|||
export default NetworkImage; |
|||
@ -0,0 +1,48 @@ |
|||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|||
import { getMarriageProfile } from "./use-profile-main"; |
|||
|
|||
const mocks = vi.hoisted(() => ({ |
|||
get: vi.fn(), |
|||
setCachedEntryPath: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("@/lib/http", () => ({ |
|||
http: { get: mocks.get }, |
|||
})); |
|||
|
|||
vi.mock("@/lib/entry-route-cache", () => ({ |
|||
setCachedMarriageEntryPath: mocks.setCachedEntryPath, |
|||
})); |
|||
|
|||
describe("getMarriageProfile", () => { |
|||
beforeEach(() => { |
|||
vi.clearAllMocks(); |
|||
}); |
|||
|
|||
it("persists the resolved route for a completed profile", async () => { |
|||
const profile = { |
|||
status: "pending_info", |
|||
gender: "female", |
|||
is_registering_for_self: true, |
|||
}; |
|||
mocks.get.mockResolvedValue({ data: profile }); |
|||
|
|||
await expect(getMarriageProfile()).resolves.toBe(profile); |
|||
|
|||
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith("/questions-list"); |
|||
}); |
|||
|
|||
it("clears the route for an incomplete onboarding profile", async () => { |
|||
mocks.get.mockResolvedValue({ |
|||
data: { |
|||
status: "pending_onboarding", |
|||
gender: null, |
|||
is_registering_for_self: null, |
|||
}, |
|||
}); |
|||
|
|||
await getMarriageProfile(); |
|||
|
|||
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith(null); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,39 @@ |
|||
import { beforeEach, describe, expect, it, vi } from "vitest"; |
|||
|
|||
const mocks = vi.hoisted(() => ({ |
|||
getClientCookie: vi.fn(), |
|||
setCachedEntryPath: vi.fn(), |
|||
})); |
|||
|
|||
vi.mock("./cookies", () => ({ |
|||
getClientCookie: mocks.getClientCookie, |
|||
})); |
|||
|
|||
vi.mock("./entry-route-cache", () => ({ |
|||
setCachedMarriageEntryPath: mocks.setCachedEntryPath, |
|||
})); |
|||
|
|||
describe("authBridge", () => { |
|||
beforeEach(() => { |
|||
vi.resetModules(); |
|||
vi.clearAllMocks(); |
|||
mocks.getClientCookie.mockReturnValue(null); |
|||
window.HABIB_TOKEN = undefined; |
|||
window.addFlutterResponseListener = vi.fn(() => vi.fn()); |
|||
window.sessionStorage.clear(); |
|||
}); |
|||
|
|||
it("treats a missing token as anonymous and clears the entry cache", async () => { |
|||
const { authBridge } = await import("./auth-bridge"); |
|||
|
|||
expect(authBridge.isAuthenticated()).toBe(false); |
|||
expect(mocks.setCachedEntryPath).toHaveBeenCalledWith(null); |
|||
}); |
|||
|
|||
it("treats an empty token as anonymous", async () => { |
|||
window.HABIB_TOKEN = ""; |
|||
const { authBridge } = await import("./auth-bridge"); |
|||
|
|||
expect(authBridge.isAuthenticated()).toBe(false); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,46 @@ |
|||
import { afterEach, describe, expect, it } from "vitest"; |
|||
import { |
|||
getAuthenticatedCachedEntryPath, |
|||
getCachedMarriageEntryPath, |
|||
MARRIAGE_ENTRY_PATH_COOKIE, |
|||
normalizeCachedMarriageEntryPath, |
|||
setCachedMarriageEntryPath, |
|||
} from "./entry-route-cache"; |
|||
|
|||
describe("entry route cache", () => { |
|||
afterEach(() => { |
|||
document.cookie = `${MARRIAGE_ENTRY_PATH_COOKIE}=; Path=/; Max-Age=0`; |
|||
}); |
|||
|
|||
it("accepts only known authenticated routes", () => { |
|||
expect(normalizeCachedMarriageEntryPath("/questions-list")).toBe( |
|||
"/questions-list", |
|||
); |
|||
expect(normalizeCachedMarriageEntryPath("%2Ffinding-match")).toBe( |
|||
"/finding-match", |
|||
); |
|||
expect(normalizeCachedMarriageEntryPath("https://example.com")).toBeNull(); |
|||
expect(normalizeCachedMarriageEntryPath("//example.com")).toBeNull(); |
|||
expect(normalizeCachedMarriageEntryPath("/intro")).toBeNull(); |
|||
}); |
|||
|
|||
it("requires a real token before trusting the cached route", () => { |
|||
expect( |
|||
getAuthenticatedCachedEntryPath("token", "/questions-list"), |
|||
).toBe("/questions-list"); |
|||
expect( |
|||
getAuthenticatedCachedEntryPath("NO_TOKEN", "/questions-list"), |
|||
).toBeNull(); |
|||
expect( |
|||
getAuthenticatedCachedEntryPath("", "/questions-list"), |
|||
).toBeNull(); |
|||
}); |
|||
|
|||
it("persists and clears a safe client entry route", () => { |
|||
setCachedMarriageEntryPath("/questions-list"); |
|||
expect(getCachedMarriageEntryPath()).toBe("/questions-list"); |
|||
|
|||
setCachedMarriageEntryPath(null); |
|||
expect(getCachedMarriageEntryPath()).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,65 @@ |
|||
import { |
|||
deleteClientCookie, |
|||
getClientCookie, |
|||
setClientCookie, |
|||
} from "./cookies"; |
|||
|
|||
export const MARRIAGE_ENTRY_PATH_COOKIE = "HABIB_MARRIAGE_ENTRY_PATH"; |
|||
|
|||
const AUTHENTICATED_ENTRY_PATHS = new Set([ |
|||
"/terms", |
|||
"/questions-list", |
|||
"/finding-match", |
|||
"/request-accepted", |
|||
"/new-match", |
|||
"/request-sent", |
|||
]); |
|||
|
|||
export function isAuthenticatedToken(token: string | null | undefined) { |
|||
return Boolean(token && token !== "NO_TOKEN" && token.trim() !== ""); |
|||
} |
|||
|
|||
export function normalizeCachedMarriageEntryPath( |
|||
value: string | null | undefined, |
|||
) { |
|||
if (!value) { |
|||
return null; |
|||
} |
|||
|
|||
let decodedValue: string; |
|||
try { |
|||
decodedValue = decodeURIComponent(value); |
|||
} catch { |
|||
return null; |
|||
} |
|||
|
|||
return AUTHENTICATED_ENTRY_PATHS.has(decodedValue) ? decodedValue : null; |
|||
} |
|||
|
|||
export function getAuthenticatedCachedEntryPath( |
|||
token: string | null | undefined, |
|||
cachedPath: string | null | undefined, |
|||
) { |
|||
if (!isAuthenticatedToken(token)) { |
|||
return null; |
|||
} |
|||
|
|||
return normalizeCachedMarriageEntryPath(cachedPath); |
|||
} |
|||
|
|||
export function getCachedMarriageEntryPath() { |
|||
return normalizeCachedMarriageEntryPath( |
|||
getClientCookie(MARRIAGE_ENTRY_PATH_COOKIE), |
|||
); |
|||
} |
|||
|
|||
export function setCachedMarriageEntryPath(path: string | null) { |
|||
const safePath = normalizeCachedMarriageEntryPath(path); |
|||
|
|||
if (!safePath) { |
|||
deleteClientCookie(MARRIAGE_ENTRY_PATH_COOKIE); |
|||
return; |
|||
} |
|||
|
|||
setClientCookie(MARRIAGE_ENTRY_PATH_COOKIE, safePath); |
|||
} |
|||
@ -0,0 +1,14 @@ |
|||
export function cn(...inputs: (string | undefined | null | false | Record<string, boolean>)[]): string { |
|||
const classes: string[] = []; |
|||
for (const input of inputs) { |
|||
if (!input) continue; |
|||
if (typeof input === "string") { |
|||
classes.push(input); |
|||
} else if (typeof input === "object") { |
|||
for (const [key, val] of Object.entries(input)) { |
|||
if (val) classes.push(key); |
|||
} |
|||
} |
|||
} |
|||
return classes.join(" "); |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue