From 843dfc4a7d8ff6bdd6c62aaefaf828de622cdd58 Mon Sep 17 00:00:00 2001 From: Luke Vella Date: Wed, 23 Apr 2025 13:10:05 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20setup=20screen=20(#1682)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/package.json | 1 + apps/web/public/locales/en/app.json | 7 +- apps/web/src/app/[locale]/(space)/layout.tsx | 5 +- .../components/language-preference.tsx | 5 +- apps/web/src/app/[locale]/layout.tsx | 73 +++++---- apps/web/src/app/[locale]/setup/page.tsx | 61 ++++++++ apps/web/src/features/setup/actions.ts | 99 ++++++++++++ apps/web/src/features/setup/api.ts | 30 ++++ .../features/setup/components/setup-form.tsx | 145 ++++++++++++++++++ apps/web/src/features/setup/schema.ts | 17 ++ apps/web/src/features/setup/types.ts | 14 ++ apps/web/src/i18n/client.tsx | 22 ++- apps/web/src/i18n/i18n.ts | 29 ++-- apps/web/src/i18n/server.ts | 23 ++- apps/web/src/i18n/server/get-locale.ts | 11 ++ apps/web/src/middleware.ts | 21 +-- apps/web/src/next-auth.ts | 24 ++- .../src/utils/locale/get-locale-from-path.ts | 11 -- yarn.lock | 21 +++ 19 files changed, 526 insertions(+), 93 deletions(-) create mode 100644 apps/web/src/app/[locale]/setup/page.tsx create mode 100644 apps/web/src/features/setup/actions.ts create mode 100644 apps/web/src/features/setup/api.ts create mode 100644 apps/web/src/features/setup/components/setup-form.tsx create mode 100644 apps/web/src/features/setup/schema.ts create mode 100644 apps/web/src/features/setup/types.ts create mode 100644 apps/web/src/i18n/server/get-locale.ts delete mode 100644 apps/web/src/utils/locale/get-locale-from-path.ts diff --git a/apps/web/package.json b/apps/web/package.json index 7a1d10f02..3e528fa03 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -57,6 +57,7 @@ "crypto": "^1.0.1", "dayjs": "^1.11.10", "i18next": "^24.2.2", + "i18next-http-backend": "^3.0.2", "i18next-icu": "^2.3.0", "i18next-resources-to-backend": "^1.2.1", "ics": "^3.1.0", diff --git a/apps/web/public/locales/en/app.json b/apps/web/public/locales/en/app.json index 67f1900f9..e9ff72c0d 100644 --- a/apps/web/public/locales/en/app.json +++ b/apps/web/public/locales/en/app.json @@ -328,5 +328,10 @@ "unconfirmedEventsEmptyStateTitle": "No Unconfirmed Events", "unconfirmedEventsEmptyStateDescription": "Unconfirmed events will show up here.", "canceledEventsEmptyStateTitle": "No Canceled Events", - "canceledEventsEmptyStateDescription": "Canceled events will show up here." + "canceledEventsEmptyStateDescription": "Canceled events will show up here.", + "setupFormTitle": "Setup", + "setupFormDescription": "Finish setting up your account.", + "errorNotAuthenticated": "Not authenticated", + "errorInvalidFields": "Invalid fields. Please check your input.", + "errorDatabaseUpdateFailed": "Database error: Failed to update settings." } diff --git a/apps/web/src/app/[locale]/(space)/layout.tsx b/apps/web/src/app/[locale]/(space)/layout.tsx index ac55bb1e6..f16c3f810 100644 --- a/apps/web/src/app/[locale]/(space)/layout.tsx +++ b/apps/web/src/app/[locale]/(space)/layout.tsx @@ -4,8 +4,8 @@ import { SidebarInset, SidebarTrigger } from "@rallly/ui/sidebar"; import Link from "next/link"; import { OptimizedAvatarImage } from "@/components/optimized-avatar-image"; -import { getUser } from "@/data/get-user"; import { CommandMenu } from "@/features/navigation/command-menu"; +import { getOnboardedUser } from "@/features/setup/api"; import { TimezoneProvider } from "@/features/timezone/client/context"; import { AppSidebar } from "./components/sidebar/app-sidebar"; @@ -17,7 +17,8 @@ export default async function Layout({ }: { children: React.ReactNode; }) { - const user = await getUser(); + const user = await getOnboardedUser(); + return ( diff --git a/apps/web/src/app/[locale]/(space)/settings/components/language-preference.tsx b/apps/web/src/app/[locale]/(space)/settings/components/language-preference.tsx index cbdfc9d66..e124d52eb 100644 --- a/apps/web/src/app/[locale]/(space)/settings/components/language-preference.tsx +++ b/apps/web/src/app/[locale]/(space)/settings/components/language-preference.tsx @@ -3,7 +3,6 @@ import { Button } from "@rallly/ui/button"; import { Form, FormField, FormItem, FormLabel } from "@rallly/ui/form"; import { ArrowUpRight } from "lucide-react"; import Link from "next/link"; -import { useRouter } from "next/navigation"; import { useForm } from "react-hook-form"; import { z } from "zod"; @@ -20,7 +19,6 @@ type FormData = z.infer; export const LanguagePreference = () => { const { i18n } = useTranslation(); - const router = useRouter(); const form = useForm({ defaultValues: { language: i18n.language, @@ -34,7 +32,8 @@ export const LanguagePreference = () => {
{ await updatePreferences({ locale: data.language }); - router.refresh(); + i18n.changeLanguage(data.language); + form.reset({ language: data.language }); })} > - + - - - - - - - - - - {children} - - - - - - - - - + + + + + + + + + {children} + + + + + + + + diff --git a/apps/web/src/app/[locale]/setup/page.tsx b/apps/web/src/app/[locale]/setup/page.tsx new file mode 100644 index 000000000..02436b387 --- /dev/null +++ b/apps/web/src/app/[locale]/setup/page.tsx @@ -0,0 +1,61 @@ +import { redirect } from "next/navigation"; + +import { ProfilePicture } from "@/app/[locale]/(space)/settings/profile/profile-picture"; +import { Logo } from "@/components/logo"; +import { Trans } from "@/components/trans"; +import { getUser } from "@/data/get-user"; +import { SetupForm } from "@/features/setup/components/setup-form"; +import { onboardedUserSchema } from "@/features/setup/schema"; +import { getTranslation } from "@/i18n/server"; + +export default async function SetupPage() { + const user = await getUser(); + + const isUserOnboarded = onboardedUserSchema.safeParse(user).success; + + if (isUserOnboarded) { + redirect("/"); + } + + return ( +
+
+
+
+ +
+
+

+ +

+

+ +

+
+
+ + +
+
+
+
+ ); +} + +export async function generateMetadata() { + const { t } = await getTranslation(); + return { + title: t("setupFormTitle", { + defaultValue: "Setup", + }), + }; +} diff --git a/apps/web/src/features/setup/actions.ts b/apps/web/src/features/setup/actions.ts new file mode 100644 index 000000000..f1c17858e --- /dev/null +++ b/apps/web/src/features/setup/actions.ts @@ -0,0 +1,99 @@ +"use server"; + +import { prisma } from "@rallly/database"; +import { posthog } from "@rallly/posthog/server"; +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { getTranslation } from "@/i18n/server"; +import { auth } from "@/next-auth"; + +import { setupSchema } from "./schema"; + +export type SetupFormState = { + message?: string | null; + errors?: { + name?: string[]; + timeZone?: string[]; + locale?: string[]; + }; +}; + +export async function updateUserSetup( + formData: FormData, +): Promise { + const { t } = await getTranslation(); + const session = await auth(); + + if (!session?.user?.id) { + return { + message: t("errorNotAuthenticated", { + defaultValue: "Not authenticated", + }), + }; + } + + const validatedFields = setupSchema.safeParse({ + name: formData.get("name"), + timeZone: formData.get("timeZone"), + locale: formData.get("locale"), + }); + + if (!validatedFields.success) { + const errors = validatedFields.error.flatten().fieldErrors; + const translatedErrors = Object.entries(errors).reduce( + (acc, [key, value]) => { + acc[key as keyof typeof errors] = value?.map((errKey) => + t(errKey, { defaultValue: `Invalid ${key}` }), + ); + return acc; + }, + {} as Required["errors"], + ); + + return { + errors: translatedErrors, + message: t("errorInvalidFields", { + defaultValue: "Invalid fields. Please check your input.", + }), + }; + } + + const { name, timeZone, locale } = validatedFields.data; + + try { + await prisma.user.update({ + where: { id: session.user.id }, + data: { + name, + timeZone, + locale, + }, + }); + } catch (error) { + console.error("Failed to update user setup:", error); + return { + message: t("errorDatabaseUpdateFailed", { + defaultValue: "Database error: Failed to update settings.", + }), + }; + } + + posthog?.capture({ + event: "user_setup_completed", + distinctId: session.user.id, + properties: { + $set: { + name, + timeZone, + locale, + }, + }, + }); + + await posthog?.shutdown(); + + revalidatePath("/", "layout"); + + redirect("/"); +} diff --git a/apps/web/src/features/setup/api.ts b/apps/web/src/features/setup/api.ts new file mode 100644 index 000000000..e05e44c58 --- /dev/null +++ b/apps/web/src/features/setup/api.ts @@ -0,0 +1,30 @@ +import { headers } from "next/headers"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +import { getUser } from "@/data/get-user"; + +import { onboardedUserSchema } from "./schema"; + +export const getOnboardedUser = cache(async () => { + const user = await getUser(); + + const onboardedUser = onboardedUserSchema.safeParse(user); + + if (!onboardedUser.success) { + const headerList = headers(); + const pathname = headerList.get("x-pathname"); + const searchParams = + pathname && pathname !== "/" + ? `?redirectTo=${encodeURIComponent(pathname)}` + : ""; + redirect(`/setup${searchParams}`); + } + + return { + ...user, + timeZone: onboardedUser.data.timeZone, + locale: onboardedUser.data.locale, + name: onboardedUser.data.name, + }; +}); diff --git a/apps/web/src/features/setup/components/setup-form.tsx b/apps/web/src/features/setup/components/setup-form.tsx new file mode 100644 index 000000000..d4553b9d8 --- /dev/null +++ b/apps/web/src/features/setup/components/setup-form.tsx @@ -0,0 +1,145 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Button } from "@rallly/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@rallly/ui/form"; +import { Input } from "@rallly/ui/input"; +import * as React from "react"; +import { useForm } from "react-hook-form"; + +import { LanguageSelect } from "@/components/poll/language-selector"; +import { TimeZoneSelect } from "@/components/time-zone-picker/time-zone-select"; +import { Trans } from "@/components/trans"; +import { useTimezone } from "@/features/timezone"; +import { useTranslation } from "@/i18n/client"; + +import { updateUserSetup } from "../actions"; +import { type SetupFormValues, setupSchema } from "../schema"; + +interface SetupFormProps { + defaultValues?: Partial; +} + +export function SetupForm({ defaultValues }: SetupFormProps) { + const { timezone } = useTimezone(); + const { i18n } = useTranslation(); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [serverError, setServerError] = React.useState(null); + const form = useForm({ + resolver: zodResolver(setupSchema), + defaultValues: { + name: defaultValues?.name ?? "", + timeZone: defaultValues?.timeZone || timezone, + locale: defaultValues?.locale || i18n.language, + }, + }); + + async function onSubmit(data: SetupFormValues) { + setIsSubmitting(true); + setServerError(null); + + // Construct FormData for the server action + const formData = new FormData(); + formData.append("name", data.name); + formData.append("timeZone", data.timeZone); + formData.append("locale", data.locale); + + const result = await updateUserSetup(formData); + + setIsSubmitting(false); + + if (result?.message) { + setServerError(result.message); + } + } + + return ( + + + {serverError && ( +

+ {serverError} +

+ )} +
+ ( + + + + + + + + + + )} + /> + + ( + + + + + + + + + + )} + /> + + ( + + + + + + + { + field.onChange(value); + i18n.changeLanguage(value); + }} + /> + + + + )} + /> +
+
+ +
+ + + ); +} diff --git a/apps/web/src/features/setup/schema.ts b/apps/web/src/features/setup/schema.ts new file mode 100644 index 000000000..b0fafd7fc --- /dev/null +++ b/apps/web/src/features/setup/schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const setupSchema = z.object({ + name: z.string().min(1), + timeZone: z.string().min(1), + locale: z.string().min(1), +}); + +export type SetupFormValues = z.infer; + +export const onboardedUserSchema = z.object({ + name: z.string().min(1), + timeZone: z.string().min(1), + locale: z.string().min(1), +}); + +export type OnboardedUser = z.infer; diff --git a/apps/web/src/features/setup/types.ts b/apps/web/src/features/setup/types.ts new file mode 100644 index 000000000..da1adf5ed --- /dev/null +++ b/apps/web/src/features/setup/types.ts @@ -0,0 +1,14 @@ +// Assuming you have a base User type somewhere, e.g., in prisma types or a shared types file +import type { User as BaseUser } from "@prisma/client"; // Or wherever your base User type is + +// Define the type for a user who has completed onboarding +// It extends the base user but makes required onboarding fields non-nullable +export interface OnboardedUser extends BaseUser { + name: string; // Guaranteed to be a string + timeZone: string; // Guaranteed to be a string + locale: string; // Guaranteed to be a string +} + +// You might also want a type for the potentially un-onboarded user, +// which could just be your BaseUser type +export type PotentiallyUnonboardedUser = BaseUser; diff --git a/apps/web/src/i18n/client.tsx b/apps/web/src/i18n/client.tsx index 5e3da29d8..6bcbf388c 100644 --- a/apps/web/src/i18n/client.tsx +++ b/apps/web/src/i18n/client.tsx @@ -1,5 +1,5 @@ "use client"; -import { useParams } from "next/navigation"; +import httpBackend from "i18next-http-backend"; import React from "react"; import { I18nextProvider, @@ -14,12 +14,20 @@ export function useTranslation() { return useTranslationOrg("app"); } -export function I18nProvider({ children }: { children: React.ReactNode }) { - const params = useParams<{ locale: string }>(); - const locale = params?.locale ?? defaultNS; - +export function I18nProvider({ + children, + locale, +}: { + children: React.ReactNode; + locale: string; +}) { const res = useAsync(async () => { - return await initI18next(locale, "app"); + return await initI18next({ + lng: locale, + middleware: (i18n) => { + i18n.use(httpBackend); + }, + }); }); if (!res.value) { @@ -27,7 +35,7 @@ export function I18nProvider({ children }: { children: React.ReactNode }) { } return ( - + {children} ); diff --git a/apps/web/src/i18n/i18n.ts b/apps/web/src/i18n/i18n.ts index 3609c720a..590a55175 100644 --- a/apps/web/src/i18n/i18n.ts +++ b/apps/web/src/i18n/i18n.ts @@ -1,22 +1,21 @@ -import type { Namespace } from "i18next"; +import type { i18n, Namespace } from "i18next"; import { createInstance } from "i18next"; import ICU from "i18next-icu"; -import resourcesToBackend from "i18next-resources-to-backend"; import { initReactI18next } from "react-i18next/initReactI18next"; import { getOptions } from "./settings"; -export const initI18next = async (lng: string, ns: Namespace) => { - const i18nInstance = createInstance(); - await i18nInstance - .use(initReactI18next) - .use(ICU) - .use( - resourcesToBackend( - (language: string, namespace: string) => - import(`../../public/locales/${language}/${namespace}.json`), - ), - ) - .init(getOptions(lng, ns)); - return i18nInstance; +export const initI18next = async ({ + lng, + ns, + middleware, +}: { + lng: string; + ns?: Namespace; + middleware: (i18n: i18n) => void; +}) => { + const i18nInstance = createInstance().use(initReactI18next).use(ICU); + middleware(i18nInstance); + const t = await i18nInstance.init(getOptions(lng, ns)); + return { t, i18n: i18nInstance }; }; diff --git a/apps/web/src/i18n/server.ts b/apps/web/src/i18n/server.ts index 8834f719d..f4916a9f3 100644 --- a/apps/web/src/i18n/server.ts +++ b/apps/web/src/i18n/server.ts @@ -1,14 +1,25 @@ +import resourcesToBackend from "i18next-resources-to-backend"; + import { defaultNS } from "@/i18n/settings"; -import { getLocaleFromPath } from "@/utils/locale/get-locale-from-path"; import { initI18next } from "./i18n"; +import { getLocale } from "./server/get-locale"; export async function getTranslation(localeOverride?: string) { - const localeFromPath = getLocaleFromPath(); - const locale = localeOverride || localeFromPath; - const i18nextInstance = await initI18next(locale, defaultNS); + const locale = localeOverride || getLocale(); + const { i18n } = await initI18next({ + lng: locale, + middleware: (i18n) => { + i18n.use( + resourcesToBackend( + (language: string, namespace: string) => + import(`../../public/locales/${language}/${namespace}.json`), + ), + ); + }, + }); return { - t: i18nextInstance.getFixedT(locale, defaultNS), - i18n: i18nextInstance, + t: i18n.getFixedT(locale, defaultNS), + i18n, }; } diff --git a/apps/web/src/i18n/server/get-locale.ts b/apps/web/src/i18n/server/get-locale.ts new file mode 100644 index 000000000..dd1acac63 --- /dev/null +++ b/apps/web/src/i18n/server/get-locale.ts @@ -0,0 +1,11 @@ +import { defaultLocale } from "@rallly/languages"; +import { headers } from "next/headers"; + +export function getLocale() { + const headersList = headers(); + const localeFromHeader = headersList.get("x-locale"); + if (!localeFromHeader) { + return defaultLocale; + } + return localeFromHeader; +} diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index f8ce3ae85..c90fefbe7 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -10,30 +10,23 @@ const supportedLocales = Object.keys(languages); export const middleware = withAuth(async (req) => { const { nextUrl } = req; const newUrl = nextUrl.clone(); + const pathname = newUrl.pathname; const isLoggedIn = req.auth?.user?.email; // if the user is already logged in, don't let them access the login page - if ( - /^\/(login)/.test(newUrl.pathname) && - isLoggedIn && - !newUrl.searchParams.get("invalidSession") - ) { + if (/^\/(login)/.test(pathname) && isLoggedIn) { newUrl.pathname = "/"; return NextResponse.redirect(newUrl); } - // Check if locale is specified in cookie - let locale = req.auth?.user?.locale; - if (locale && supportedLocales.includes(locale)) { - newUrl.pathname = `/${locale}${newUrl.pathname}`; - } else { - // Check if locale is specified in header - locale = getPreferredLocale(req); - newUrl.pathname = `/${locale}${newUrl.pathname}`; + const locale = req.auth?.user?.locale || getPreferredLocale(req); + if (supportedLocales.includes(locale)) { + newUrl.pathname = `/${locale}${pathname}`; } const res = NextResponse.rewrite(newUrl); - res.headers.set("x-pathname", newUrl.pathname); + res.headers.set("x-locale", locale); + res.headers.set("x-pathname", pathname); if (req.auth?.user?.id) { await withPostHog(res, { distinctID: req.auth.user.id }); diff --git a/apps/web/src/next-auth.ts b/apps/web/src/next-auth.ts index dd1a1235e..63edb07ab 100644 --- a/apps/web/src/next-auth.ts +++ b/apps/web/src/next-auth.ts @@ -66,6 +66,22 @@ const { }, }, events: { + createUser({ user }) { + if (user.id) { + posthog?.capture({ + distinctId: user.id, + event: "register", + properties: { + $set: { + name: user.name, + email: user.email, + timeZone: user.timeZone ?? undefined, + locale: user.locale ?? undefined, + }, + }, + }); + } + }, signIn({ user, account }) { if (user.id) { posthog?.capture({ @@ -76,8 +92,8 @@ const { $set: { name: user.name, email: user.email, - timeZone: user.timeZone, - locale: user.locale, + timeZone: user.timeZone ?? undefined, + locale: user.locale ?? undefined, }, }, }); @@ -168,7 +184,6 @@ const { select: { name: true, email: true, - locale: true, timeFormat: true, timeZone: true, weekStart: true, @@ -180,7 +195,6 @@ const { token.name = user.name; token.email = user.email; token.picture = user.image; - token.locale = user.locale; token.timeFormat = user.timeFormat; token.timeZone = user.timeZone; token.weekStart = user.weekStart; @@ -218,7 +232,7 @@ const requireUser = async () => { */ export const getUserId = async () => { const session = await auth(); - return session?.user?.email ? session.user.id : null; + return session?.user?.email ? session.user.id : undefined; }; export const getLoggedIn = async () => { diff --git a/apps/web/src/utils/locale/get-locale-from-path.ts b/apps/web/src/utils/locale/get-locale-from-path.ts deleted file mode 100644 index 0b4608746..000000000 --- a/apps/web/src/utils/locale/get-locale-from-path.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defaultLocale, supportedLngs } from "@rallly/languages"; -import { headers } from "next/headers"; - -export function getLocaleFromPath() { - const headersList = headers(); - const pathname = headersList.get("x-pathname") || defaultLocale; - const localeFromPath = pathname.split("/")[1]; - return supportedLngs.includes(localeFromPath) - ? localeFromPath - : defaultLocale; -} diff --git a/yarn.lock b/yarn.lock index 5b6a70c88..e6dcc7e52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8102,6 +8102,13 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" +cross-fetch@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-4.0.0.tgz#f037aef1580bb3a1a35164ea2a848ba81b445983" + integrity sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz" @@ -9925,6 +9932,13 @@ hyphenate-style-name@^1.0.3: resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.0.4.tgz" integrity sha512-ygGZLjmXfPHj+ZWh6LwbC37l43MhfztxetbFCoYTM2VjkIUpeHgSNn7QIyVFj7YQ1Wl9Cbw5sholVJPzWvC2MQ== +i18next-http-backend@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/i18next-http-backend/-/i18next-http-backend-3.0.2.tgz#7c8daa31aa69679e155ec1f96a37846225bdf907" + integrity sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g== + dependencies: + cross-fetch "4.0.0" + i18next-icu@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/i18next-icu/-/i18next-icu-2.3.0.tgz" @@ -11583,6 +11597,13 @@ nice-try@^1.0.4: resolved "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-fetch@^2.6.7: version "2.6.9" resolved "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.9.tgz"