From 117503081b2f0ba72f9402c0492edeb5c98fbc7f Mon Sep 17 00:00:00 2001 From: Wanjohi <71614375+wanjohiryan@users.noreply.github.com> Date: Wed, 5 Mar 2025 22:44:00 +0300 Subject: [PATCH] =?UTF-8?q?=E2=AD=90=20feat(www):=20Finish=20up=20on=20the?= =?UTF-8?q?=20onboarding=20(#202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This is an attempt to finish up on the onboarding and creating a team ## Type of Change - [ ] Bug fix (non-breaking change) - [x] New feature (non-breaking change) - [ ] Breaking change (fix or feature that changes existing functionality) - [ ] Documentation update - [ ] Other (please describe): ## Checklist - [ ] I have updated relevant documentation - [x] My code follows the project's coding style - [x] My changes generate no new warnings/errors --- packages/core/src/examples.ts | 8 +- packages/core/src/polar.ts | 8 - packages/core/src/polar/index.ts | 169 ++++++++++ packages/core/src/polar/polar.sql.ts | 22 ++ packages/core/src/team/index.ts | 23 +- packages/core/src/user/index.ts | 12 +- packages/functions/src/api/account.ts | 8 +- packages/www/index.html | 4 +- packages/www/package.json | 7 +- packages/www/src/App.tsx | 38 +-- packages/www/src/pages/default-state.tsx | 7 - packages/www/src/pages/new.tsx | 121 ++++++- .../www/src/providers/{auth.tsx => auth.ts} | 6 +- packages/www/src/ui/button.ts | 34 ++ packages/www/src/ui/form.tsx | 310 ++++++++++++++++++ packages/www/src/ui/index.ts | 6 + packages/www/src/ui/layout.tsx | 108 ++++-- packages/www/src/ui/text.tsx | 15 +- packages/www/src/ui/theme.ts | 33 +- 19 files changed, 828 insertions(+), 111 deletions(-) delete mode 100644 packages/core/src/polar.ts create mode 100644 packages/core/src/polar/index.ts create mode 100644 packages/core/src/polar/polar.sql.ts delete mode 100644 packages/www/src/pages/default-state.tsx rename packages/www/src/providers/{auth.tsx => auth.ts} (96%) create mode 100644 packages/www/src/ui/button.ts create mode 100644 packages/www/src/ui/form.tsx create mode 100644 packages/www/src/ui/index.ts diff --git a/packages/core/src/examples.ts b/packages/core/src/examples.ts index bb59bb93..793ac7f3 100644 --- a/packages/core/src/examples.ts +++ b/packages/core/src/examples.ts @@ -1,4 +1,3 @@ -import { teamID } from "./drizzle/types"; import { prefixes } from "./utils"; export module Examples { export const Id = (prefix: keyof typeof prefixes) => @@ -18,7 +17,7 @@ export module Examples { name: "John Does' Team", slug: "john_doe", } - + export const Member = { id: Id("member"), email: "john@example.com", @@ -26,4 +25,9 @@ export module Examples { timeSeen: new Date("2025-02-23T13:39:52.249Z"), } + export const Polar = { + teamID: Id("team"), + timeSeen: new Date("2025-02-23T13:39:52.249Z"), + } + } \ No newline at end of file diff --git a/packages/core/src/polar.ts b/packages/core/src/polar.ts deleted file mode 100644 index 660aff52..00000000 --- a/packages/core/src/polar.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { Resource } from "sst"; -import { Polar as PolarSdk } from "@polar-sh/sdk"; - -const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" }); - -export module Polar { - export const client = polar; -} \ No newline at end of file diff --git a/packages/core/src/polar/index.ts b/packages/core/src/polar/index.ts new file mode 100644 index 00000000..36c214a6 --- /dev/null +++ b/packages/core/src/polar/index.ts @@ -0,0 +1,169 @@ +import { z } from "zod"; +import { fn } from "../utils"; +import { Resource } from "sst"; +import { eq, and } from "../drizzle"; +import { useTeam } from "../actor"; +import { createEvent } from "../event"; +import { polarTable, Standing } from "./polar.sql"; +import { Polar as PolarSdk } from "@polar-sh/sdk"; +import { useTransaction } from "../drizzle/transaction"; + +const polar = new PolarSdk({ accessToken: Resource.PolarSecret.value, server: Resource.App.stage !== "production" ? "sandbox" : "production" }); + +export module Polar { + export const client = polar; + + export const Info = z.object({ + teamID: z.string(), + customerID: z.string(), + subscriptionID: z.string().nullable(), + subscriptionItemID: z.string().nullable(), + standing: z.enum(Standing), + }); + + export type Info = z.infer; + + export const Checkout = z.object({ + annual: z.boolean().optional(), + successUrl: z.string(), + cancelUrl: z.string(), + }); + + export const CheckoutSession = z.object({ + url: z.string().nullable(), + }); + + export const CustomerSubscriptionEventType = [ + "created", + "updated", + "deleted", + ] as const; + + export const Events = { + CustomerSubscriptionEvent: createEvent( + "polar.customer-subscription-event", + z.object({ + type: z.enum(CustomerSubscriptionEventType), + status: z.string(), + teamID: z.string().min(1), + customerID: z.string().min(1), + subscriptionID: z.string().min(1), + subscriptionItemID: z.string().min(1), + }), + ), + }; + + export function get() { + return useTransaction(async (tx) => + tx + .select() + .from(polarTable) + .where(eq(polarTable.teamID, useTeam())) + .execute() + .then((rows) => rows.map(serialize).at(0)), + ); + } + + export const fromUserEmail = fn(z.string().min(1), async (email) => { + try { + const customers = await client.customers.list({ email }) + + if (customers.result.items.length === 0) { + return await client.customers.create({ email }) + } else { + return customers.result.items[0] + } + + } catch (err) { + //FIXME: This is the issue [Polar.sh/#5147](https://github.com/polarsource/polar/issues/5147) + // console.log("error", err) + return undefined + } + }) + + export const setCustomerID = fn(Info.shape.customerID, async (customerID) => + useTransaction(async (tx) => + tx + .insert(polarTable) + .values({ + teamID: useTeam(), + customerID, + standing: "new", + }) + .execute(), + ), + ); + + export const setSubscription = fn( + Info.pick({ + subscriptionID: true, + subscriptionItemID: true, + }), + (input) => + useTransaction(async (tx) => + tx + .update(polarTable) + .set({ + subscriptionID: input.subscriptionID, + subscriptionItemID: input.subscriptionItemID, + }) + .where(eq(polarTable.teamID, useTeam())) + .returning() + .execute() + .then((rows) => rows.map(serialize).at(0)), + ), + ); + + export const removeSubscription = fn( + z.string().min(1), + (stripeSubscriptionID) => + useTransaction((tx) => + tx + .update(polarTable) + .set({ + subscriptionItemID: null, + subscriptionID: null, + }) + .where(and(eq(polarTable.subscriptionID, stripeSubscriptionID))) + .execute(), + ), + ); + + export const setStanding = fn( + Info.pick({ + subscriptionID: true, + standing: true, + }), + (input) => + useTransaction((tx) => + tx + .update(polarTable) + .set({ standing: input.standing }) + .where(and(eq(polarTable.subscriptionID, input.subscriptionID!))) + .execute(), + ), + ); + + export const fromCustomerID = fn(Info.shape.customerID, (customerID) => + useTransaction((tx) => + tx + .select() + .from(polarTable) + .where(and(eq(polarTable.customerID, customerID))) + .execute() + .then((rows) => rows.map(serialize).at(0)), + ), + ); + + function serialize( + input: typeof polarTable.$inferSelect, + ): z.infer { + return { + teamID: input.teamID, + customerID: input.customerID, + subscriptionID: input.subscriptionID, + subscriptionItemID: input.subscriptionItemID, + standing: input.standing, + }; + } +} \ No newline at end of file diff --git a/packages/core/src/polar/polar.sql.ts b/packages/core/src/polar/polar.sql.ts new file mode 100644 index 00000000..6e28748c --- /dev/null +++ b/packages/core/src/polar/polar.sql.ts @@ -0,0 +1,22 @@ +import { timestamps, teamID } from "../drizzle/types"; +import { teamIndexes, teamTable } from "../team/team.sql"; +import { pgTable, text, varchar } from "drizzle-orm/pg-core"; + +export const Standing = ["new", "good", "overdue"] as const; + +export const polarTable = pgTable( + "polar", + { + teamID: teamID.teamID.primaryKey().references(() => teamTable.id), + ...timestamps, + customerID: varchar("customer_id", { length: 255 }).notNull(), + subscriptionID: varchar("subscription_id", { length: 255 }), + subscriptionItemID: varchar("subscription_item_id", { + length: 255, + }), + standing: text("standing", { enum: Standing }).notNull(), + }, + (table) => ({ + ...teamIndexes(table), + }) +) \ No newline at end of file diff --git a/packages/core/src/team/index.ts b/packages/core/src/team/index.ts index 1db157bc..3d7a882e 100644 --- a/packages/core/src/team/index.ts +++ b/packages/core/src/team/index.ts @@ -3,13 +3,13 @@ import { Resource } from "sst"; import { bus } from "sst/aws/bus"; import { Common } from "../common"; import { createID, fn } from "../utils"; -import { VisibleError } from "../error"; import { Examples } from "../examples"; import { teamTable } from "./team.sql"; import { createEvent } from "../event"; -import { assertActor } from "../actor"; +import { assertActor, withActor } from "../actor"; import { and, eq, sql } from "../drizzle"; import { memberTable } from "../member/member.sql"; +import { HTTPException } from 'hono/http-exception'; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; export module Team { @@ -45,11 +45,11 @@ export module Team { ), }; - export class WorkspaceExistsError extends VisibleError { + export class TeamExistsError extends HTTPException { constructor(slug: string) { super( - "team.slug_exists", - `there is already a workspace named "${slug}"`, + 400, + { message: `There is already a team named "${slug}"`, } ); } } @@ -65,15 +65,16 @@ export module Team { slug: input.slug, name: input.name }) - .onConflictDoNothing() - .returning({ insertedID: teamTable.id }) + .onConflictDoNothing({ target: teamTable.slug }) - if (result.length === 0) throw new WorkspaceExistsError(input.slug); + if (!result.rowCount) throw new TeamExistsError(input.slug); await afterTx(() => - bus.publish(Resource.Bus, Events.Created, { - teamID: id, - }), + withActor({ type: "system", properties: { teamID: id } }, () => + bus.publish(Resource.Bus, Events.Created, { + teamID: id, + }) + ), ); return id; }) diff --git a/packages/core/src/user/index.ts b/packages/core/src/user/index.ts index 439e1590..a27f1ad2 100644 --- a/packages/core/src/user/index.ts +++ b/packages/core/src/user/index.ts @@ -1,4 +1,6 @@ import { z } from "zod"; +import { Polar } from "../polar"; +import { Team } from "../team"; import { bus } from "sst/aws/bus"; import { Common } from "../common"; import { createID, fn } from "../utils"; @@ -11,7 +13,6 @@ import { assertActor, withActor } from "../actor"; import { memberTable } from "../member/member.sql"; import { and, eq, isNull, asc, getTableColumns, sql } from "../drizzle"; import { afterTx, createTransaction, useTransaction } from "../drizzle/transaction"; -import { Team } from "../team"; export module User { @@ -106,12 +107,8 @@ export module User { //FIXME: Do this much later, as Polar.sh has so many inconsistencies for fuck's sake - // const customer = await Polar.client.customers.create({ - // email: input.email, - // metadata: { - // userID, - // }, - // }); + const customer = await Polar.fromUserEmail(input.email) + console.log("customer", customer) const name = sanitizeUsername(input.name); @@ -131,6 +128,7 @@ export module User { avatarUrl: input.avatarUrl, email: input.email, discriminator: Number(discriminator), + polarCustomerID: customer?.id }) await afterTx(() => withActor({ diff --git a/packages/functions/src/api/account.ts b/packages/functions/src/api/account.ts index 32ce1de7..c5ab6163 100644 --- a/packages/functions/src/api/account.ts +++ b/packages/functions/src/api/account.ts @@ -42,8 +42,10 @@ export module AccountApi { }), async (c) => { const actor = assertActor("user"); - const currentUser = await User.fromID(actor.properties.userID) - if (!currentUser) return c.json({ error: "This account does not exist, it may have been deleted" }, 404) + const [currentUser, teams] = await Promise.all([User.fromID(actor.properties.userID), User.teams()]) + + if (!currentUser) return c.json({ error: "This account does not exist; it may have been deleted" }, 404) + const { id, email, name, polarCustomerID, avatarUrl, discriminator } = currentUser return c.json({ @@ -51,10 +53,10 @@ export module AccountApi { id, email, name, + teams, avatarUrl, discriminator, polarCustomerID, - teams: await User.teams(), } }, 200); }, diff --git a/packages/www/index.html b/packages/www/index.html index 3ec7a691..536f6e06 100644 --- a/packages/www/index.html +++ b/packages/www/index.html @@ -9,12 +9,12 @@ { ( - - {props.children} - - // - // - // - // - // - // - // - // - // - // {props.children} - // - // - // - // - // - // - // - // + // + // {props.children} + props.children + // )} > - {/* - - - - {WorkspaceRoute} */} - + { diff --git a/packages/www/src/pages/default-state.tsx b/packages/www/src/pages/default-state.tsx deleted file mode 100644 index c13befc0..00000000 --- a/packages/www/src/pages/default-state.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export function DefaultState() { - return ( -
- We are logging you in -
- ) -} \ No newline at end of file diff --git a/packages/www/src/pages/new.tsx b/packages/www/src/pages/new.tsx index 3319de5b..4b6b2651 100644 --- a/packages/www/src/pages/new.tsx +++ b/packages/www/src/pages/new.tsx @@ -1,15 +1,124 @@ +import * as v from "valibot" +import { styled } from "@macaron-css/solid"; import { Text } from "@nestri/www/ui/text"; +import { utility } from "@nestri/www/ui/utility"; +import { theme } from "@nestri/www/ui/theme"; +import { FormField, Input, Select } from "@nestri/www/ui/form"; import { Container, FullScreen } from "@nestri/www/ui/layout"; +import { createForm, required, email, valiForm } from "@modular-forms/solid"; +import { Button } from "@nestri/www/ui"; + +// const nameRegex = /^[a-z]+$/ + +const FieldList = styled("div", { + base: { + width: "100%", + maxWidth: 380, + ...utility.stack(5), + }, +}); + +const Hr = styled("hr", { + base: { + border: 0, + backgroundColor: theme.color.gray.d400, + width: "100%", + height: 1, + } +}) + +const Plan = { + Pro: 'Pro', + Basic: 'Basic', +} as const; + +const schema = v.object({ + plan: v.pipe( + v.enum(Plan), + ), + display_name: v.pipe( + v.string(), + v.maxLength(32, 'Please use 32 characters at maximum.'), + ), + slug: v.pipe( + v.string(), + v.minLength(2, 'Please use 2 characters at minimum.'), + // v.regex(nameRegex, "Use only small letters, no numbers or special characters"), + v.maxLength(48, 'Please use 48 characters at maximum.'), + ) +}) + +export function CreateTeamComponent() { + const [form, { Form, Field }] = createForm({ + validate: valiForm(schema), + }); -export function TeamCreate() { return ( - - - Your first deploy is just a sign-up away. - - + + + + Create a Team + + + Choose something that your teammates will recognize + +
+
+
+ + + {(field, props) => ( + + + + )} + + + {(field, props) => ( + +