From bc12942bba6ca3b552d0a5cbadc5fb7d639436c3 Mon Sep 17 00:00:00 2001 From: Oscar Beaumont Date: Tue, 19 Mar 2024 01:36:28 +0800 Subject: [PATCH] Waitlist on landing --- .env.example | 2 +- .vscode/settings.json | 3 +- apps/landing/app.config.ts | 2 + apps/landing/loadEnv.ts | 14 ++ apps/landing/package.json | 3 +- apps/landing/src/routes/company.tsx | 17 +- apps/landing/src/routes/index.tsx | 296 +++++++++++++++----------- apps/web/src/api/routes/waitlist.ts | 31 ++- packages/ui/src/dialog.tsx | 8 +- packages/ui/src/forms/Form.tsx | 42 +++- packages/ui/src/forms/InputField.tsx | 10 +- packages/ui/src/forms/SelectField.tsx | 67 ++++++ packages/ui/src/forms/index.tsx | 1 + packages/ui/vite.ts | 2 +- pnpm-lock.yaml | 3 + 15 files changed, 340 insertions(+), 161 deletions(-) create mode 100644 apps/landing/loadEnv.ts create mode 100644 packages/ui/src/forms/SelectField.tsx diff --git a/.env.example b/.env.example index 7305bd0c..04b9d14b 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,4 @@ AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= # Used for landing only -MATTRAX_CLOUD_ORIGIN=http://localhost:3000 # Should point to `apps/web` \ No newline at end of file +VITE_MATTRAX_CLOUD_ORIGIN=http://localhost:3000 # Should point to `apps/web` \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 3d4ef8ca..c1bc9825 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,6 @@ }, "editor.codeActionsOnSave": { "quickfix.biome": "explicit" - } + }, + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/apps/landing/app.config.ts b/apps/landing/app.config.ts index 960f8d06..27108199 100644 --- a/apps/landing/app.config.ts +++ b/apps/landing/app.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from "@solidjs/start/config"; +import { monorepoRoot } from "./loadEnv"; import mattraxUI from "@mattrax/ui/vite"; export default defineConfig({ @@ -9,6 +10,7 @@ export default defineConfig({ }, }, vite: { + envDir: monorepoRoot, plugins: [mattraxUI], server: { fs: { diff --git a/apps/landing/loadEnv.ts b/apps/landing/loadEnv.ts new file mode 100644 index 00000000..0e26b520 --- /dev/null +++ b/apps/landing/loadEnv.ts @@ -0,0 +1,14 @@ +import { loadEnv } from "vite"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const monorepoRoot = join(__dirname, "../.."); + +process.env = { + ...process.env, + ...loadEnv("production", monorepoRoot, ""), +}; + +export { monorepoRoot }; diff --git a/apps/landing/package.json b/apps/landing/package.json index 652f878c..07d280e2 100644 --- a/apps/landing/package.json +++ b/apps/landing/package.json @@ -24,6 +24,7 @@ "h3": "^1.11.1", "solid-js": "^1.8.15", "vinxi": "=0.3.4", - "vite": "^5.1.5" + "vite": "^5.1.5", + "zod": "^3.22.4" } } diff --git a/apps/landing/src/routes/company.tsx b/apps/landing/src/routes/company.tsx index 1fe997b0..f13d1a0f 100644 --- a/apps/landing/src/routes/company.tsx +++ b/apps/landing/src/routes/company.tsx @@ -3,14 +3,19 @@ export default function Page() {
-
+

- Mattrax Technologies + Mattrax Inc.

- We at Mattrax Technologies are setting out to build better tools - for IT administrators.
Empowering smaller teams to move - faster and more reliably than was ever possible. + + We at Mattrax are setting out to build better tools for IT + administrators. + + + Empowering smaller teams to move faster without compromising on + end-user experience. +

@@ -18,7 +23,7 @@ export default function Page() {
-
+
-
- Features -
+
+ +
+
  • @@ -156,7 +169,17 @@ export default function Page() { > @mattraxapp {" "} - for updates
    or + or join the{" "} + + Discord + {" "} + for updates +
    and
    {" "}
- {/*

Sign up for the waitlist:

-
- - - - -
*/} - - {/*
-
-
-
-

- Join the Waitlist -

-

- Enter your email to join the waitlist.{" "} -

-
-
-
- - -
-
- - -
-
- -
Option A
-
Option B
-
Option C
-
Option D
- -
- -
-
-
-
*/}
@@ -281,19 +209,147 @@ export default function Page() { ); } -// // TODO: Use `InputField` from Mattrax's UI package once it's broken out. -// function Input( -// props: Omit, "class"> & { label: JSX.Element }, -// ) { -// return ( -//
-// -// -//
-// ); -// } +function Waitlist() { + return ( + Join Waitlist}> + + + Join Waitlist + + We will keep you updated with Mattrax's development! +
You can unsubscribe at any time. +
+
+ + +
+
+ ); +} + +const getObjectKeys = (obj: T) => + Object.keys(obj) as (keyof T)[]; + +function zodEnumFromObjectKeys(obj: Record) { + return z.enum([getObjectKeys(obj)[0]!, ...getObjectKeys(obj)]); +} + +const interestReasons = { + personal: "Personal", + "internal-it-team": "Internal IT Team", + "msp-provider": "MSP Provider", + other: "Other", +} as const; + +const deploymentMethod = { + managedCloud: "Managed Cloud", + privateCloud: "Private Cloud", + onprem: "On Premise", + other: "Other", +} as const; + +if (typeof import.meta.env.VITE_MATTRAX_CLOUD_ORIGIN !== "string") + throw new Error("Missing 'VITE_MATTRAX_CLOUD_ORIGIN' env variable!"); +const waitlistEndpoint = new URL( + "/api/waitlist", + import.meta.env.VITE_MATTRAX_CLOUD_ORIGIN, +).toString(); + +function DropdownBody() { + const controller = useController(); + + const schema = z.object({ + email: z.string().email(), + name: z.string().optional(), + interest: zodEnumFromObjectKeys(interestReasons), + deployment: zodEnumFromObjectKeys(deploymentMethod), + }); + + const form = createZodForm({ + schema, + onSubmit: async ({ value }) => { + const resp = await fetch(waitlistEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(value), + }); + if (!resp.ok) { + console.error( + "Error applying to waitlist", + resp.status, + await resp.text(), + ); + alert( + "Error adding you to the waitlist. Please try again or email hello@mattrax.app", + ); + return; + } + + controller.setOpen(false); + }, + }); + + // `state().isValid` seems to be always `true` (probs cause `createZodForm` only does validation on submit) // TODO: Maybe fix this properly? + const isFormValid = () => schema.safeParse(form.state.values).success; + + return ( +
+ + + + ( + + {interestReasons[props.item.rawValue]} + + )} + > + + > + {(state) => interestReasons[state.selectedOption()]} + + + + + + ( + + {deploymentMethod[props.item.rawValue]} + + )} + > + + > + {(state) => deploymentMethod[state.selectedOption()]} + + + + + + + + ); +} diff --git a/apps/web/src/api/routes/waitlist.ts b/apps/web/src/api/routes/waitlist.ts index b16020db..600c461d 100644 --- a/apps/web/src/api/routes/waitlist.ts +++ b/apps/web/src/api/routes/waitlist.ts @@ -15,22 +15,19 @@ const waitlistRequest = z.object({ deployment: z.enum(waitlistDeploymentMethod), }); -export const waitlistRouter = new Hono().post( - "/register", - async (c) => { - const result = waitlistRequest.safeParse(await c.req.json()); - if (!result.success) { - c.status(400); - return c.text("Invalid request!"); - } +export const waitlistRouter = new Hono().post("/", async (c) => { + const result = waitlistRequest.safeParse(await c.req.json()); + if (!result.success) { + c.status(400); + return c.text("Invalid request!"); + } - await db.insert(waitlist).values({ - email: result.data.email, - name: result.data.name, - interest: result.data.interest, - deployment: result.data.deployment, - }); + await db.insert(waitlist).values({ + email: result.data.email, + name: result.data.name, + interest: result.data.interest, + deployment: result.data.deployment, + }); - return c.text("ok"); - }, -); + return c.text("ok"); +}); diff --git a/packages/ui/src/dialog.tsx b/packages/ui/src/dialog.tsx index 409c207a..1e9242e5 100644 --- a/packages/ui/src/dialog.tsx +++ b/packages/ui/src/dialog.tsx @@ -3,7 +3,11 @@ import type { Component, ComponentProps, JSX, ParentProps } from "solid-js"; import { splitProps } from "solid-js"; import { cn } from "./lib"; -import { Controller, ControllerProvider, createController } from "./controller"; +import { + type Controller, + ControllerProvider, + createController, +} from "./controller"; // An easy wrapper on the dialog primitives const Dialog: Component< @@ -13,7 +17,7 @@ const Dialog: Component< }> > = (props) => ( - {props.trigger && {props.trigger}} + {props.trigger && {props.trigger}} {props.children} diff --git a/packages/ui/src/forms/Form.tsx b/packages/ui/src/forms/Form.tsx index fbf18023..bd3c185f 100644 --- a/packages/ui/src/forms/Form.tsx +++ b/packages/ui/src/forms/Form.tsx @@ -1,11 +1,14 @@ -// TODO: Do this properly copying Brendan's blog post -// TODO: Input validation built into the components - import { useBeforeLeave } from "@solidjs/router"; -import { FormOptions, createForm } from "@tanstack/solid-form"; +import { type FormOptions, createForm } from "@tanstack/solid-form"; import { zodValidator } from "@tanstack/zod-form-adapter"; -import { ComponentProps, createMemo, splitProps } from "solid-js"; -import { z } from "zod"; +import { + type ComponentProps, + createMemo, + splitProps, + createSignal, + onCleanup, +} from "solid-js"; +import type { z } from "zod"; export function createZodForm( opts: Omit< @@ -15,7 +18,7 @@ export function createZodForm( schema: S; }, ) { - return createForm( + const form = createForm( createMemo(() => ({ ...opts, validatorAdapter: zodValidator, @@ -24,6 +27,31 @@ export function createZodForm( }, })), ); + + const [state, setState] = createSignal(form.store.state); + let skipGetter = false; // `setState` will call the getter causing a recursive loop so this skips a set operation if `true`. + + onCleanup( + form.store.subscribe(() => { + skipGetter = true; + setState(form.store.state); + }), + ); + + // A workaround for Tanstack Form's lack of proper reactivity // TODO: Maybe upstream PR? + Object.defineProperty(form, "state", { + get: () => state(), + set: (v) => { + if (skipGetter) { + skipGetter = false; + return; + } + + setState(v); + }, + }); + + return form; } export type FormProps = Omit< diff --git a/packages/ui/src/forms/InputField.tsx b/packages/ui/src/forms/InputField.tsx index 00112eb2..ba8cbda2 100644 --- a/packages/ui/src/forms/InputField.tsx +++ b/packages/ui/src/forms/InputField.tsx @@ -1,9 +1,9 @@ -import { DeepKeys, FieldApi, FormApi } from "@tanstack/solid-form"; +import type { DeepKeys, FieldApi, FormApi } from "@tanstack/solid-form"; import { - Accessor, - Component, - ComponentProps, - JSX, + type Accessor, + type Component, + type ComponentProps, + type JSX, createUniqueId, splitProps, } from "solid-js"; diff --git a/packages/ui/src/forms/SelectField.tsx b/packages/ui/src/forms/SelectField.tsx new file mode 100644 index 00000000..e3288954 --- /dev/null +++ b/packages/ui/src/forms/SelectField.tsx @@ -0,0 +1,67 @@ +import type { DeepKeys, FieldApi, FormApi } from "@tanstack/solid-form"; +import { + type Accessor, + type Component, + type ComponentProps, + type JSX, + createUniqueId, + splitProps, +} from "solid-js"; + +import { clsx } from "clsx"; +import { Select, Label } from ".."; + +export function SelectField< + TData extends Record, + TName extends DeepKeys, +>( + props: Omit< + ComponentProps, + "id" | "value" | "onChange" | "onBlur" | "form" + > & { + form: FormApi; + fieldClass?: string; + name: TName; + label?: string; + labelClasses?: string; + }, +) { + const [_, selectProps] = splitProps(props, [ + "form", + "name", + "label", + "labelClasses", + "fieldClass", + ]); + const id = createUniqueId(); + + const form = { + get Field() { + return props.form.Field as unknown as Component<{ + name: TName; + children: (field: Accessor>) => JSX.Element; + }>; + }, + }; + + return ( + + {(field) => ( +
+ {props.label !== undefined && ( + + )} +