diff --git a/README.md b/README.md index 9d6546e..95cf9ea 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ New SaaS businesses cut corners that are hard to fix later. LetsGo gives you a f This project provides you with the architecture and the tooling that will put your startup on a solid foundation from day one. It helps you save months of work leading to the launch while allowing you to focus on the essence of your product. The day you let the first customer in, you have no technical debt. As you grow, you can continue focusing your resources on what matters most: your customers and your product. -image +LetsGo Architecture LetsGo does it by providing a prescriptive architecture implemented with a modern set of technologies and robust operational tooling for managing your app in AWS. On day one you get more than most startups build in the first two years: @@ -51,6 +51,7 @@ Let's go! [Develop the API](./docs/how-to/develop-the-api.md) [Develop the worker](./docs/how-to/develop-the-worker.md) [Enqueue asynchronous work](./docs/how-to/enqueue-asynchronous-work.md) +[Schedule asynchronous work](./docs/how-to/schedule-asynchronous-work.md) [Access data in the database from code](./docs/how-to/access-data-in-the-database-from-code.md) [Process the contact form](./docs/how-to/process-the-contact-form.md) [Manage configuration](./docs/how-to/manage-configuration.md) diff --git a/apps/api/src/routes/me.ts b/apps/api/src/routes/me.ts index c026d15..1d31490 100644 --- a/apps/api/src/routes/me.ts +++ b/apps/api/src/routes/me.ts @@ -22,6 +22,7 @@ export const meHandler: RequestHandler = async (req, res, next) => { isBuiltInIssuer( (request.user?.decodedJwt?.payload as JwtPayload).iss || "" ); + const returnAccessToken = req.query.returnAccessToken !== undefined; let tenants = await getTenantsOfIdentity({ identity: request.user.identity, }); @@ -54,6 +55,7 @@ export const meHandler: RequestHandler = async (req, res, next) => { const body: GetMeResponse = { identityId: request.user.identityId, identity: request.user.identity, + accessToken: returnAccessToken ? request.user.jwt : undefined, tenants, }; return res.json(body); diff --git a/apps/api/src/routes/tenant.ts b/apps/api/src/routes/tenant.ts index 78d0b60..193f094 100644 --- a/apps/api/src/routes/tenant.ts +++ b/apps/api/src/routes/tenant.ts @@ -212,7 +212,7 @@ router.get( // Redirect the browser back to the dashboard with appropriate next step let location: string; if (response.status === "succeeded") { - location = createAbsoluteWebUrl(`/manage/${tenantId}/settings`); + location = createAbsoluteWebUrl(`/manage/${tenantId}/subscription`); } else if (response.status === "processing") { location = createAbsoluteWebUrl( `/manage/${tenantId}/paymentmethod/processing` @@ -320,7 +320,12 @@ router.post( // No card number yet, it will be created next }; await putTenant(tenant); - res.json(response); + // Subscription is activated immediately if the Stripe user had a balance on their account. + if (response.status === "active") { + res.status(200).send(); + } else { + res.json(response); + } return; } else { // current plan is not Stripe, new plan is not Stripe @@ -424,8 +429,13 @@ router.get( const invitations = await getInvitations({ tenantId: req.params.tenantId, }); + const sortedInvitations = invitations.sort((i1, i2) => { + const e1 = new Date(i1.expiresAt).getTime(); + const e2 = new Date(i2.expiresAt).getTime(); + return e1 > e2 ? 1 : e1 < e2 ? -1 : 0; + }); const response: GetInvitationsResponse = { - invitations: invitations.map(pruneResponse), + invitations: sortedInvitations.map(pruneResponse), }; res.json(response); } catch (e) { diff --git a/apps/ops/package.json b/apps/ops/package.json index 0e4d4f1..6899ebc 100644 --- a/apps/ops/package.json +++ b/apps/ops/package.json @@ -21,12 +21,13 @@ "@aws-sdk/client-ecr": "^3.421.0", "@aws-sdk/client-iam": "^3.421.0", "@aws-sdk/client-lambda": "^3.427.0", + "@aws-sdk/client-scheduler": "^3.476.0", "@aws-sdk/client-sqs": "^3.425.0", "@aws-sdk/client-ssm": "^3.421.0", "@aws-sdk/client-sts": "^3.421.0", "@letsgo/constants": "*", - "@letsgo/queue": "*", "@letsgo/db": "*", + "@letsgo/queue": "*", "@letsgo/trust": "*", "chalk": "4", "commander": "^11.0.0", diff --git a/apps/ops/src/aws/iam.ts b/apps/ops/src/aws/iam.ts index 5531468..2d4f467 100644 --- a/apps/ops/src/aws/iam.ts +++ b/apps/ops/src/aws/iam.ts @@ -35,7 +35,7 @@ export const LambdaAssumeRolePolicy = { { Effect: "Allow", Principal: { - Service: "lambda.amazonaws.com", + Service: ["lambda.amazonaws.com", "scheduler.amazonaws.com"], }, Action: "sts:AssumeRole", }, diff --git a/apps/ops/src/aws/lambda.ts b/apps/ops/src/aws/lambda.ts index 07e9635..1d69532 100644 --- a/apps/ops/src/aws/lambda.ts +++ b/apps/ops/src/aws/lambda.ts @@ -13,6 +13,7 @@ import { CreateEventSourceMappingCommandOutput, UpdateEventSourceMappingCommand, GetEventSourceMappingCommand, + DeleteEventSourceMappingCommand, } from "@aws-sdk/client-lambda"; import { setLogGroupRetentionPolicy } from "./cloudwatch"; import { Logger } from "../commands/defaults"; @@ -24,6 +25,7 @@ import { getOneLetsGoQueue, getQueueArnFromQueueUrl } from "./sqs"; import { getTagsAsObject } from "./defaults"; import chalk from "chalk"; import { ServiceUrls, getServiceUrlEnvironmentVariables } from "./apprunner"; +import { getAccountId } from "./sts"; const MaxFunctionWaitTime = 60 * 5; // 5 minutes const MaxEventSourceMappingWaitTime = 60 * 5; // 5 minutes @@ -145,6 +147,38 @@ export async function enableOrDisableEventSourceMapping( } } +async function deleteEventSourceMapping( + region: string, + deployment: string, + functionName: string, + logger: Logger +) { + logger(`deleting event source mapping...`, "aws:lambda"); + const queueUrl = await getOneLetsGoQueue(region, deployment, logger); + if (!queueUrl) { + logger( + chalk.yellow(`cannot locate event source mapping: queue not found`), + "aws:sqs" + ); + return; + } + const queueArn = await getQueueArnFromQueueUrl(region, queueUrl || ""); + const eventSource = await getEventSourceMapping( + region, + functionName, + queueArn, + logger + ); + const lambda = getLambdaClient(region); + if (eventSource) { + const command = new DeleteEventSourceMappingCommand({ + UUID: eventSource.UUID || "", + }); + await lambda.send(command); + } + logger("event source mapping deleted", "aws:lambda"); +} + async function getEventSourceMappingFromUuid(region: string, uuid: string) { const lambda = getLambdaClient(region); const getEventSourceMappingCommand = new GetEventSourceMappingCommand({ @@ -641,9 +675,11 @@ export async function ensureLambda( export async function deleteLambda( region: string, + deployment: string, functionName: string, logger: Logger ) { + await deleteEventSourceMapping(region, deployment, functionName, logger); const existing = await getLambda(region, functionName); if (!existing) { return; @@ -656,3 +692,7 @@ export async function deleteLambda( await lambda.send(deleteCommand); logger(`function ${functionName} deleted`, "aws:lambda"); } + +export async function getFunctionArn(region: string, functionName: string) { + return `arn:aws:lambda:${region}:${await getAccountId()}:function:${functionName}`; +} diff --git a/apps/ops/src/aws/scheduler.ts b/apps/ops/src/aws/scheduler.ts new file mode 100644 index 0000000..c78ebba --- /dev/null +++ b/apps/ops/src/aws/scheduler.ts @@ -0,0 +1,240 @@ +import { + CreateScheduleCommand, + CreateScheduleGroupCommand, + DeleteScheduleCommand, + DeleteScheduleGroupCommand, + FlexibleTimeWindowMode, + GetScheduleCommand, + GetScheduleCommandOutput, + ScheduleState, + SchedulerClient, + TagResourceCommand, + UpdateScheduleCommand, +} from "@aws-sdk/client-scheduler"; +import { getTags } from "./defaults"; +import { WorkerSettings } from "@letsgo/constants"; +import { Logger } from "../commands/defaults"; +import { LetsGoDeploymentConfig } from "./ssm"; +import { getFunctionArn } from "./lambda"; +import { getRoleArn } from "./iam"; +import { getAccountId } from "./sts"; +import { get } from "http"; +import chalk from "chalk"; + +const apiVersion = "2021-06-30"; + +function getSchedulerClient(region: string) { + return new SchedulerClient({ + apiVersion, + region, + }); +} + +async function getScheduleGroupArn(region: string, scheduleGroupName: string) { + return `arn:aws:scheduler:${region}:${await getAccountId()}:schedule-group/${scheduleGroupName}`; +} + +export async function getSchedule( + region: string, + deployment: string, + settings: WorkerSettings +): Promise { + const scheduler = getSchedulerClient(region); + const command = new GetScheduleCommand({ + Name: settings.getScheduleName(deployment), + GroupName: settings.getScheduleName(deployment), + }); + try { + return await scheduler.send(command); + } catch (e: any) { + if (e.name !== "ResourceNotFoundException") { + throw e; + } + } + return undefined; +} + +export async function enableOrDisableSchedule( + region: string, + deployment: string, + settings: WorkerSettings, + start: boolean, + logger: Logger +) { + logger(`${start ? "starting" : "stopping"} scheduler...`, "aws:scheduler"); + const existingSchedule = await getSchedule(region, deployment, settings); + if (!existingSchedule) { + logger( + chalk.yellow(`cannot ${start ? "start" : "stop"} scheduler: not found`), + "aws:scheduler" + ); + return; + } + const scheduler = getSchedulerClient(region); + const command = new UpdateScheduleCommand({ + Name: existingSchedule.Name, + GroupName: existingSchedule.GroupName, + ScheduleExpression: existingSchedule.ScheduleExpression, + ScheduleExpressionTimezone: existingSchedule.ScheduleExpressionTimezone, + FlexibleTimeWindow: existingSchedule.FlexibleTimeWindow, + Target: existingSchedule.Target, + State: start ? ScheduleState.ENABLED : ScheduleState.DISABLED, + }); + await scheduler.send(command); + const tagCommand = new TagResourceCommand({ + ResourceArn: await getScheduleGroupArn( + region, + existingSchedule.GroupName || "" + ), + Tags: getTags(region, deployment), + }); + await scheduler.send(tagCommand); + + logger(`scheduler ${start ? "started" : "stopped"}`, "aws:scheduler"); +} + +export async function deleteSchedule( + region: string, + deployment: string, + settings: WorkerSettings, + logger: Logger +): Promise { + const scheduler = getSchedulerClient(region); + const name = settings.getScheduleName(deployment); + logger(`deleting schedule ${name}...`, "aws:scheduler"); + const command = new DeleteScheduleCommand({ + Name: name, + GroupName: name, + }); + try { + await scheduler.send(command); + } catch (e: any) { + if (e.name !== "ResourceNotFoundException") { + throw e; + } + } + const deleteScheduleGroupCommand = new DeleteScheduleGroupCommand({ + Name: name, + }); + try { + await scheduler.send(deleteScheduleGroupCommand); + } catch (e: any) { + if (e.name !== "ResourceNotFoundException") { + throw e; + } + } +} + +async function updateSchedule( + region: string, + deployment: string, + settings: WorkerSettings, + config: LetsGoDeploymentConfig, + logger: Logger, + existingSchedule: GetScheduleCommandOutput +): Promise { + logger(`updating schedule '${existingSchedule.Name}'...`, "aws:scheduler"); + const scheduler = getSchedulerClient(region); + // Check if schedule needs to be updated + const existingAttributes: { [key: string]: any } = { + ScheduleExpression: existingSchedule.ScheduleExpression, + ScheduleExpressionTimezone: existingSchedule.ScheduleExpressionTimezone, + }; + const desiredAttrbutes: { [key: string]: any } = { + ScheduleExpression: config[settings.defaultConfig.schedule[0]], + ScheduleExpressionTimezone: + config[settings.defaultConfig.scheduleTimezone[0]], + }; + const needsUpdate = Object.keys(desiredAttrbutes).filter( + (key) => existingAttributes[key] !== desiredAttrbutes[key] + ); + if (needsUpdate.length > 0) { + logger( + `updating schedule attributes ${needsUpdate.join(", ")}...`, + "aws:scheduler" + ); + const command = new UpdateScheduleCommand({ + Name: existingSchedule.Name, + GroupName: existingSchedule.GroupName, + ScheduleExpression: desiredAttrbutes.ScheduleExpression, + ScheduleExpressionTimezone: desiredAttrbutes.ScheduleExpressionTimezone, + FlexibleTimeWindow: existingSchedule.FlexibleTimeWindow, + Target: existingSchedule.Target, + State: existingSchedule.State, + }); + await scheduler.send(command); + const tagCommand = new TagResourceCommand({ + ResourceArn: await getScheduleGroupArn( + region, + existingSchedule.GroupName || "" + ), + Tags: getTags(region, deployment), + }); + await scheduler.send(tagCommand); + logger(`schedule is up to date`, "aws:scheduler"); + } +} + +async function createSchedule( + region: string, + deployment: string, + settings: WorkerSettings, + config: LetsGoDeploymentConfig, + logger: Logger +): Promise { + const scheduler = getSchedulerClient(region); + const createScheduleGroupCommand = new CreateScheduleGroupCommand({ + Name: settings.getScheduleName(deployment), + Tags: getTags(region, deployment), + }); + try { + await scheduler.send(createScheduleGroupCommand); + } catch (e: any) { + if (e.name !== "ConflictException") { + throw e; + } + } + const command = new CreateScheduleCommand({ + Name: settings.getScheduleName(deployment), + GroupName: settings.getScheduleName(deployment), + ScheduleExpression: config[settings.defaultConfig.schedule[0]], + ScheduleExpressionTimezone: + config[settings.defaultConfig.scheduleTimezone[0]], + FlexibleTimeWindow: { Mode: FlexibleTimeWindowMode.OFF }, + Target: { + Arn: await getFunctionArn( + region, + settings.getLambdaFunctionName(deployment) + ), + RoleArn: await getRoleArn(settings.getRoleName(region, deployment)), + }, + State: ScheduleState.ENABLED, + }); + logger( + `creating schedule '${command.input.Name}' with expression '${command.input.ScheduleExpression}' and TZ '${command.input.ScheduleExpressionTimezone}' for the worker...`, + "aws:scheduler" + ); + await scheduler.send(command); +} + +export async function ensureSchedule( + region: string, + deployment: string, + settings: WorkerSettings, + config: LetsGoDeploymentConfig, + logger: Logger +): Promise { + const existingSchedule = await getSchedule(region, deployment, settings); + if (existingSchedule) { + return updateSchedule( + region, + deployment, + settings, + config, + logger, + existingSchedule + ); + } else { + return createSchedule(region, deployment, settings, config, logger); + } +} diff --git a/apps/ops/src/commands/defaults.ts b/apps/ops/src/commands/defaults.ts index 52177f5..e0d3eff 100644 --- a/apps/ops/src/commands/defaults.ts +++ b/apps/ops/src/commands/defaults.ts @@ -23,5 +23,9 @@ export function getArtifacts(artifacts: string[], allArtifacts: string[]) { acc[artifact] = true; return acc; }, {} as { [artifact: string]: boolean }); + if (newArtifacts.worker) { + newArtifacts["worker-queue"] = true; + newArtifacts["worker-scheduler"] = true; + } return newArtifacts; } diff --git a/apps/ops/src/commands/deploy.ts b/apps/ops/src/commands/deploy.ts index 2ae3eba..dd99e00 100644 --- a/apps/ops/src/commands/deploy.ts +++ b/apps/ops/src/commands/deploy.ts @@ -28,6 +28,7 @@ import { ensureDynamo } from "../aws/dynamodb"; import { ensureQueue } from "../aws/sqs"; import { ensureLambda } from "../aws/lambda"; import { createIssuer, getActiveIssuer } from "@letsgo/trust"; +import { ensureSchedule } from "../aws/scheduler"; const AllArtifacts = ["all", "api", "web", "db", "worker"]; @@ -252,6 +253,15 @@ async function deployWorker( serviceUrls, logger ); + // Ensure schedule exists + logger("ensuring worker schedule exists", "aws:scheduler"); + await ensureSchedule( + options.region, + options.deployment, + settings, + config, + logger + ); } program diff --git a/apps/ops/src/commands/rm.ts b/apps/ops/src/commands/rm.ts index 0a7ef4f..1a82393 100644 --- a/apps/ops/src/commands/rm.ts +++ b/apps/ops/src/commands/rm.ts @@ -22,6 +22,7 @@ import { deleteRole } from "../aws/iam"; import { deleteDynamo } from "../aws/dynamodb"; import { deleteQueue } from "../aws/sqs"; import { deleteLambda } from "../aws/lambda"; +import { deleteSchedule } from "../aws/scheduler"; const program = new Command(); @@ -119,8 +120,10 @@ async function deleteWorker( ) { const logger = createLogger("aws", region, deployment, "worker"); logger("deleting worker..."); + await deleteSchedule(region, deployment, settings, logger); await deleteLambda( region, + deployment, settings.getLambdaFunctionName(deployment), logger ); diff --git a/apps/ops/src/commands/service.ts b/apps/ops/src/commands/service.ts index 2857e6e..7e690db 100644 --- a/apps/ops/src/commands/service.ts +++ b/apps/ops/src/commands/service.ts @@ -7,8 +7,16 @@ import { import { createLogger, getArtifacts } from "./defaults"; import { enableOrDisableService } from "../aws/apprunner"; import { enableOrDisableEventSourceMapping } from "../aws/lambda"; +import { enableOrDisableSchedule } from "../aws/scheduler"; -export const AllServiceArtifacts = ["all", "api", "web", "worker"]; +export const AllServiceArtifacts = [ + "all", + "api", + "web", + "worker", + "worker-queue", + "worker-scheduler", +]; export function createStartStopCommanderAction(start: boolean) { return async (options: any): Promise => { @@ -59,7 +67,7 @@ export function createStartStopCommanderAction(start: boolean) { ), ] : []), - ...(artifacts.worker + ...(artifacts["worker-queue"] ? [ enableOrDisableEventSourceMapping( options.region, @@ -70,6 +78,17 @@ export function createStartStopCommanderAction(start: boolean) { ), ] : []), + ...(artifacts["worker-scheduler"] + ? [ + enableOrDisableSchedule( + options.region, + options.deployment, + WorkerConfiguration, + start, + logger + ), + ] + : []), ]; await Promise.all(parallel); console.log( diff --git a/apps/ops/src/commands/status.ts b/apps/ops/src/commands/status.ts index 21d617a..dc82de2 100644 --- a/apps/ops/src/commands/status.ts +++ b/apps/ops/src/commands/status.ts @@ -36,6 +36,9 @@ import { import { TableDescription } from "@aws-sdk/client-dynamodb"; import { get } from "https"; import { LogGroup } from "@aws-sdk/client-cloudwatch-logs"; +import { GetScheduleCommandOutput } from "@aws-sdk/client-scheduler"; +import { getSchedule } from "../aws/scheduler"; +import e from "express"; const AllArtifacts = ["all", "api", "web", "db", "worker"]; @@ -55,6 +58,7 @@ interface WorkerStatus { lambda?: GetFunctionCommandOutput | null; sqs?: { [key: string]: string } | { error: string } | ErrorStatus | null; eventMapping?: EventSourceMappingConfiguration | ErrorStatus | null; + schedule?: GetScheduleCommandOutput | ErrorStatus | null; logGroup?: LogGroup; } @@ -158,8 +162,14 @@ async function getWorkerStatus( eventMapping = eventSourceMappings[0]; } } - return lambda || sqs || eventMapping - ? { lambda, sqs, eventMapping, logGroup } + let schedule: GetScheduleCommandOutput | ErrorStatus | null; + try { + schedule = (await getSchedule(region, deployment, settings)) || null; + } catch (e: any) { + schedule = { error: e.message }; + } + return lambda || sqs || eventMapping || schedule || logGroup + ? { lambda, sqs, eventMapping, schedule, logGroup } : null; } @@ -400,6 +410,25 @@ function printWorkerStatus(status: WorkerStatus | null | undefined) { `${new Date(status.eventMapping?.LastModified || "")}` ); } + if (!status.schedule) { + console.log(`${chalk.bold(" Schedule")}: ${chalk.red("Not found")}`); + } else if (isErrorStatus(status.schedule)) { + console.log( + `${chalk.bold(" Schedule")}: ${chalk.red(status.schedule.error)}` + ); + } else { + console.log(`${chalk.bold(" Schedule")}`); + const mappingStateColor = + status.schedule?.State === "ENABLED" ? chalk.green : chalk.yellow; + printLine("State", mappingStateColor(status.schedule?.State || "")); + printLine("Expression", `${status.schedule?.ScheduleExpression}`); + printLine("Timezone", `${status.schedule?.ScheduleExpressionTimezone}`); + printLine("Arn", `${status.schedule?.Arn}`); + printLine( + "Updated", + `${new Date(status.schedule?.LastModificationDate || "")}` + ); + } } } diff --git a/apps/web/components.json b/apps/web/components.json new file mode 100644 index 0000000..9b9d2a7 --- /dev/null +++ b/apps/web/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.js", + "css": "src/app/global.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "components", + "utils": "components/utils" + } +} diff --git a/apps/web/next.config.js b/apps/web/next.config.js index ce29f15..5481cc0 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -2,7 +2,7 @@ const path = require("path"); module.exports = { reactStrictMode: true, - transpilePackages: ["ui"], + transpilePackages: ["ui", "lucide-react"], output: "standalone", experimental: { outputFileTracingRoot: path.join(__dirname, "../../"), diff --git a/apps/web/package.json b/apps/web/package.json index f715dc6..749a017 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,23 +12,65 @@ }, "dependencies": { "@auth0/nextjs-auth0": "^3.2.0", + "@hookform/resolvers": "^3.3.2", "@letsgo/pricing": "*", + "@letsgo/stripe": "*", "@letsgo/tenant": "*", "@letsgo/types": "*", - "@letsgo/stripe": "*", + "@radix-ui/react-accordion": "^1.1.2", + "@radix-ui/react-alert-dialog": "^1.0.5", + "@radix-ui/react-aspect-ratio": "^1.0.3", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-checkbox": "^1.0.4", + "@radix-ui/react-collapsible": "^1.0.3", + "@radix-ui/react-context-menu": "^2.1.5", + "@radix-ui/react-dialog": "^1.0.5", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-icons": "^1.3.0", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-menubar": "^1.0.4", + "@radix-ui/react-navigation-menu": "^1.1.4", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-progress": "^1.0.3", + "@radix-ui/react-radio-group": "^1.1.3", + "@radix-ui/react-scroll-area": "^1.0.5", + "@radix-ui/react-select": "^2.0.0", + "@radix-ui/react-separator": "^1.0.3", + "@radix-ui/react-slider": "^1.1.2", + "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-switch": "^1.0.3", + "@radix-ui/react-tabs": "^1.0.4", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-toggle": "^1.0.3", + "@radix-ui/react-toggle-group": "^1.0.4", + "@radix-ui/react-tooltip": "^1.0.7", "@stripe/react-stripe-js": "^2.3.1", "@stripe/stripe-js": "^2.1.10", + "class-variance-authority": "^0.7.0", + "clsx": "^2.0.0", + "cmdk": "^0.2.0", + "date-fns": "^2.30.0", + "lucide-react": "^0.294.0", "next": "^13.4.1", "react": "^18.2.0", + "react-day-picker": "^8.9.1", "react-dom": "^18.2.0", - "swr": "^2.2.4" + "react-hook-form": "^7.48.2", + "swr": "^2.2.4", + "tailwind-merge": "^2.0.0", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4" }, "devDependencies": { "@types/node": "^17.0.12", "@types/react": "^18.2.8", "@types/react-dom": "^18.2.4", + "autoprefixer": "^10.4.16", "eslint": "7.32.0", "eslint-config-custom": "*", + "postcss": "^8.4.31", + "tailwindcss": "^3.3.5", "tsconfig": "*", "typescript": "^5.1.3" } diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000..12a703d --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/logo.png b/apps/web/public/logo.png new file mode 100644 index 0000000..93910c0 Binary files /dev/null and b/apps/web/public/logo.png differ diff --git a/apps/web/src/app/(dashboard)/api/auth/[auth0]/route.ts b/apps/web/src/app/(dashboard)/api/auth/[auth0]/route.ts index 391734d..b0f0bca 100644 --- a/apps/web/src/app/(dashboard)/api/auth/[auth0]/route.ts +++ b/apps/web/src/app/(dashboard)/api/auth/[auth0]/route.ts @@ -2,8 +2,8 @@ import { AfterRefetch, AppRouteHandlerFn, Session } from "@auth0/nextjs-auth0"; import { serializeIdentity } from "@letsgo/trust"; import jwt from "jsonwebtoken"; import { NextRequest } from "next/server"; -import { apiRequest, getApiUrl } from "../../../../../components/common-server"; -import { getAuth0 } from "../../../../../components/auth0"; +import { apiRequest, getApiUrl } from "components/common-server"; +import { getAuth0 } from "components/auth0"; /** * Save the OpenId profile of the user in the database so that the management portal diff --git a/apps/web/src/app/(dashboard)/api/proxy.ts b/apps/web/src/app/(dashboard)/api/proxy.ts index da979d7..62fa3b5 100644 --- a/apps/web/src/app/(dashboard)/api/proxy.ts +++ b/apps/web/src/app/(dashboard)/api/proxy.ts @@ -12,7 +12,7 @@ import { AppRouteHandlerFnContext } from "@auth0/nextjs-auth0/dist/helpers/with-api-auth-required"; import { NextRequest, NextResponse } from "next/server"; -import { getAuth0 } from "../../../components/auth0"; +import { getAuth0 } from "components/auth0"; const methods: { [method: string]: { proxyRequestBody: boolean; proxyResponseBody: boolean }; diff --git a/apps/web/src/app/(dashboard)/api/proxy/[...path]/route.ts b/apps/web/src/app/(dashboard)/api/proxy/[...path]/route.ts index fa26127..611e7a0 100644 --- a/apps/web/src/app/(dashboard)/api/proxy/[...path]/route.ts +++ b/apps/web/src/app/(dashboard)/api/proxy/[...path]/route.ts @@ -11,7 +11,7 @@ */ import { AppRouteHandlerFn } from "@auth0/nextjs-auth0"; -import { getAuth0 } from "../../../../../components/auth0"; +import { getAuth0 } from "components/auth0"; import proxyFactory from "../../proxy"; // Delay the initialization of the proxy until the first request is received. diff --git a/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/layout.tsx b/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/layout.tsx index 1762186..a0787ca 100644 --- a/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/layout.tsx +++ b/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/layout.tsx @@ -1,7 +1,8 @@ "use client"; import { useUser } from "@auth0/nextjs-auth0/client"; -import { TenantProvider } from "../../../../../../components/TenantProvider"; +import { LoadingPlaceholder } from "components/LoadingPlaceholder"; +import { TenantProvider } from "components/TenantProvider"; export default function AcceptLayout({ children, @@ -10,8 +11,16 @@ export default function AcceptLayout({ }) { const { user, error, isLoading } = useUser(); - if (isLoading) return
Loading...
; + if (isLoading) { + return ( +
+ +
+ ); + } + if (error) throw error; + if (user) { return ( diff --git a/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/page.tsx b/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/page.tsx index 9059a72..71c8ed8 100644 --- a/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/page.tsx +++ b/apps/web/src/app/(dashboard)/join/[tenantId]/[invitationId]/accept/page.tsx @@ -1,10 +1,9 @@ "use client"; import { useRouter } from "next/navigation"; -import { useApiMutate } from "../../../../../../components/common-client"; +import { useApiMutate } from "components/common-client"; import { useEffect, useState } from "react"; -import { useTenant } from "../../../../../../components/TenantProvider"; -import { getTenant } from "@letsgo/tenant"; +import { useTenant } from "components/TenantProvider"; export default function Join({ params: { tenantId, invitationId }, @@ -29,10 +28,7 @@ export default function Join({ afterSuccess: async () => { setInvitationAccepted(true); await refreshTenants(); - // const newTenant = getTenantFromTenants(tenantId); - // setCurrentTenant(newTenant); - window.location.href = `/manage/${tenantId}/settings`; - // router.replace(`/manage/${tenantId}/settings`); + window.location.href = `/manage/${tenantId}/team`; }, }); const [invitationAccepted, setInvitationAccepted] = useState(false); diff --git a/apps/web/src/app/(dashboard)/layout.tsx b/apps/web/src/app/(dashboard)/layout.tsx index e3f5b07..f319669 100644 --- a/apps/web/src/app/(dashboard)/layout.tsx +++ b/apps/web/src/app/(dashboard)/layout.tsx @@ -1,4 +1,4 @@ -import { Auth0EnsureEnvironmentVariables } from "../../components/EnsureEnvironmentVariables"; +import { Auth0EnsureEnvironmentVariables } from "components/EnsureEnvironmentVariables"; export default Auth0EnsureEnvironmentVariables; diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/dashboard/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/dashboard/page.tsx new file mode 100644 index 0000000..25a7392 --- /dev/null +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/dashboard/page.tsx @@ -0,0 +1,16 @@ +"use client"; + +import { Dashboard } from "components/Dashboard"; + +export default function DashboardView({ + params, +}: { + params: { tenantId: string }; +}) { + const tenantId = params.tenantId as string; + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/layout.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/layout.tsx index 547688c..831aef7 100644 --- a/apps/web/src/app/(dashboard)/manage/[tenantId]/layout.tsx +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/layout.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect } from "react"; -import { useTenant } from "../../../../components/TenantProvider"; +import { useTenant } from "components/TenantProvider"; export default function CheckAccessToTenant({ children, @@ -10,14 +10,8 @@ export default function CheckAccessToTenant({ children: React.ReactNode; params: { tenantId: string }; }) { - const { - isLoading, - error, - tenants, - currentTenant, - getTenant, - setCurrentTenant, - } = useTenant(); + const { error, tenants, currentTenant, getTenant, setCurrentTenant } = + useTenant(); useEffect(() => { if (tenants) { @@ -29,12 +23,11 @@ export default function CheckAccessToTenant({ } }, [tenants, params.tenantId, setCurrentTenant, getTenant]); - if (isLoading) return
Loading...
; if (error) throw error; return currentTenant?.tenantId === params.tenantId ? (
{children}
) : ( -
Loading...
+
); } diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/[planId]/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/[planId]/page.tsx index fd1af23..b9256b5 100644 --- a/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/[planId]/page.tsx +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/[planId]/page.tsx @@ -5,10 +5,23 @@ import { getActivePlan, getPlan } from "@letsgo/pricing"; import { PostPlanRequest, PostPlanResponse } from "@letsgo/types"; import { notFound, useRouter } from "next/navigation"; import { useEffect, useState } from "react"; -import Checkout from "../../../../../../components/Checkout"; -import { StripeElements } from "../../../../../../components/StripeElements"; -import { useTenant } from "../../../../../../components/TenantProvider"; -import { useApiMutate } from "../../../../../../components/common-client"; +import Checkout from "components/Checkout"; +import { StripeElements } from "components/StripeElements"; +import { useTenant } from "components/TenantProvider"; +import { useApiMutate } from "components/common-client"; +import { Button } from "components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "components/ui/card"; +import { PlanOption } from "components/PlanOption"; +import { ArrowBigRight } from "lucide-react"; +import { Input } from "components/ui/input"; +import { Label } from "components/ui/label"; +import { cn } from "components/utils"; const EmailRegex = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/; @@ -33,7 +46,7 @@ function SwitchPlans({ if (data === undefined) { // Plan transition is complete - return to the management page refreshTenants(); - router.replace(`/manage/${params.tenantId}/settings`); + router.replace(`/manage/${params.tenantId}/subscription`); } }, }); @@ -51,7 +64,7 @@ function SwitchPlans({ if (planResponse) { // New plan requires payment information - collect it using Stripe. - // On successful completion, Stripe will redirect the browser to /manage/:tenantId/settings. + // On successful completion, Stripe will redirect the browser to /manage/:tenantId/subscription. return ( { + router.replace(`/manage/${currentTenant.tenantId}/subscription`); + }; + if (currentPlan.planId === newPlan.planId) { - return ( -
-

- You are already subscribed to this plan:{" "} - - {currentPlan.name} ({currentPlan.price}) - -

- -
- ); + router.replace(`/manage/${currentTenant.tenantId}/subscription`); + return
; } const confirmEmail = @@ -107,42 +109,52 @@ function SwitchPlans({ const isEmailValid = !confirmEmail || EmailRegex.test(email); return ( -
-

Switching Plans

-

- Current plan:{" "} - - {currentPlan.name} ({currentPlan.price}) - -

-

- New plan:{" "} - - {newPlan.name} ({newPlan.price}) - -

- {confirmEmail && ( -

- Billing e-mail address:{" "} - setEmail(e.target.value)} - style={isEmailValid ? {} : { color: "red" }} - /> -

- )} - -
+ + + Do you want to switch plans? + + +
+ +
+ +
+ +
+ {confirmEmail && ( +
+ + setEmail(e.target.value)} + className={cn({ "border-red-500": !isEmailValid })} + value={email} + /> +
+ )} +
+ + +
+
+
); } - return
Loading...
; + return
; } export default SwitchPlans; diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/page.tsx index 83fc64c..62deb1a 100644 --- a/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/page.tsx +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/newplan/page.tsx @@ -1,11 +1,12 @@ "use client"; import { ActivePlans, Plan } from "@letsgo/pricing"; +import { PlanSelector } from "components/PlanSelector"; +import { useTenant } from "components/TenantProvider"; +import { Card, CardContent, CardHeader, CardTitle } from "components/ui/card"; import { useRouter } from "next/navigation"; -import { PlanSelector } from "../../../../../components/PlanSelector"; -import { useTenant } from "../../../../../components/TenantProvider"; -function ChooseNewPlan({ params }: { params: { tenantId: string } }) { +function ChooseNewPlan() { const router = useRouter(); const { error, currentTenant } = useTenant(); @@ -19,26 +20,28 @@ function ChooseNewPlan({ params }: { params: { tenantId: string } }) { )}&from=/manage/${currentTenant?.tenantId}/newplan` ); } else { - window.location.href = `/manage/newplan/${encodeURIComponent( - plan.planId - )}`; + router.push(`/manage/newplan/${encodeURIComponent(plan.planId)}`); } }; if (currentTenant) { return ( -
-

Select new plan

- -
+ + + Select new plan + + + + + ); } - return
Loading...
; + return
; } export default ChooseNewPlan; diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/page.tsx index 72b226b..07a58fa 100644 --- a/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/page.tsx +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/page.tsx @@ -3,10 +3,11 @@ import { useUser } from "@auth0/nextjs-auth0/client"; import { PostPaymentMethodResponse } from "@letsgo/types"; import { useEffect } from "react"; -import Checkout from "../../../../../components/Checkout"; -import { StripeElements } from "../../../../../components/StripeElements"; -import { useTenant } from "../../../../../components/TenantProvider"; -import { useApiMutate } from "../../../../../components/common-client"; +import Checkout from "components/Checkout"; +import { StripeElements } from "components/StripeElements"; +import { useTenant } from "components/TenantProvider"; +import { useApiMutate } from "components/common-client"; +import { LoadingPlaceholder } from "components/LoadingPlaceholder"; function PaymentMethodUpdate() { const { error: userError, user } = useUser(); @@ -44,7 +45,7 @@ function PaymentMethodUpdate() { ); } - return
Loading...
; + return ; } export default PaymentMethodUpdate; diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/processing/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/processing/page.tsx index e991b43..0827baa 100644 --- a/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/processing/page.tsx +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/paymentmethod/processing/page.tsx @@ -1,5 +1,7 @@ "use client"; +import { Button } from "components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "components/ui/card"; import { useRouter } from "next/navigation"; function ProcessingPaymentMethodUpdate({ @@ -10,17 +12,24 @@ function ProcessingPaymentMethodUpdate({ const router = useRouter(); const handleClick = () => { - router.push(`/manage/${params.tenantId}/settings`); + router.push(`/manage/${params.tenantId}/subscription`); }; return ( -
-

- Processing payment details. We will update you when processing is - complete. -

- -
+ + + Processing payment + + +
+ Payment processing is in progress. We will update you when processing + is complete. +
+
+ +
+
+
); } diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/settings/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/settings/page.tsx deleted file mode 100644 index 3e4f3b7..0000000 --- a/apps/web/src/app/(dashboard)/manage/[tenantId]/settings/page.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { Account } from "../../../../../components/Account"; -import { Team } from "../../../../../components/Team"; -import { Invitations } from "../../../../../components/Invitations"; - -export default function Tenant({ params }: { params: { tenantId: string } }) { - const tenantId = params.tenantId as string; - return ( -
-

Tenant

-

Account

- -

Team

- -

Invitations

- -
- ); -} diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/subscription/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/subscription/page.tsx new file mode 100644 index 0000000..73685f0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/subscription/page.tsx @@ -0,0 +1,12 @@ +"use client"; + +import { Account } from "components/Account"; + +export default function Tenant({ params }: { params: { tenantId: string } }) { + const tenantId = params.tenantId as string; + return ( +
+ +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/manage/[tenantId]/team/page.tsx b/apps/web/src/app/(dashboard)/manage/[tenantId]/team/page.tsx new file mode 100644 index 0000000..1a9d861 --- /dev/null +++ b/apps/web/src/app/(dashboard)/manage/[tenantId]/team/page.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { Team } from "components/Team"; +import { Invitations } from "components/Invitations"; + +export default function ManageTeam({ + params, +}: { + params: { tenantId: string }; +}) { + const tenantId = params.tenantId as string; + return ( +
+ + +
+ ); +} diff --git a/apps/web/src/app/(dashboard)/manage/layout.tsx b/apps/web/src/app/(dashboard)/manage/layout.tsx index 738f526..486d9f7 100644 --- a/apps/web/src/app/(dashboard)/manage/layout.tsx +++ b/apps/web/src/app/(dashboard)/manage/layout.tsx @@ -1,42 +1,87 @@ "use client"; import { useUser } from "@auth0/nextjs-auth0/client"; +import { LoadingPlaceholder } from "components/LoadingPlaceholder"; +import { SidebarNav } from "components/SidebarNav"; +import { useTenant } from "components/TenantProvider"; +import { TenantSelector } from "components/TenantSelector"; +import { UserNav } from "components/UserNav"; +import Image from "next/image"; import Link from "next/link"; -import Navbar from "../../../components/Navbar"; -import { TenantSelector } from "../../../components/TenantSelector"; +import logo from "../../../../public/logo.png"; + +function getSidebarNavItems(tenantId: string) { + return [ + { + title: "Dashboard", + href: `/manage/${tenantId}/dashboard`, + }, + { + title: "Profile", + href: `/manage/profile`, + }, + { + title: "Team", + href: `/manage/${tenantId}/team`, + }, + { + title: "Subscription", + href: `/manage/${tenantId}/subscription`, + }, + ]; +} export default function ManageLayout({ children, }: { children: React.ReactNode; }) { - const { user, error, isLoading } = useUser(); + const { error: tenantError, currentTenant } = useTenant(); + const { user, error: userError, isLoading: isUserLoading } = useUser(); - if (isLoading) return
Loading...
; + if (!isUserLoading && !user) { + // User is not logged in - redirect to the login page + window.location.href = `/api/auth/login?returnTo=${ + window.location.pathname + }${window.location.search || ""}${window.location.hash || ""}`; + } + + const error = userError || tenantError; if (error) throw error; - if (user) { + if (!user || !currentTenant) { return ( -
- -
- Home • Tenant:{" "} - •{" "} - {user?.name || "Profile"} •{" "} - Tenant •{" "} - Contact •{" "} - Logout -
-
-
{children}
+
+
); } - // User is not logged in - redirect to the login page - window.location.href = `/api/auth/login?returnTo=${window.location.pathname}${ - window.location.search || "" - }${window.location.hash || ""}`; - - return <>; + return ( +
+ {/* Top navigation */} +
+
+
+ + LetsGo + +
|
+ +
+
+ +
+
+
+
+ {/* Side navigation */} + + {/* Main dashboard body */} +
{children}
+
+
+ ); } diff --git a/apps/web/src/app/(dashboard)/manage/newplan/[planId]/page.tsx b/apps/web/src/app/(dashboard)/manage/newplan/[planId]/page.tsx index f726ccb..c815cbd 100644 --- a/apps/web/src/app/(dashboard)/manage/newplan/[planId]/page.tsx +++ b/apps/web/src/app/(dashboard)/manage/newplan/[planId]/page.tsx @@ -2,8 +2,9 @@ import { useEffect } from "react"; import { useRouter, notFound } from "next/navigation"; -import { useTenant } from "../../../../../components/TenantProvider"; +import { useTenant } from "components/TenantProvider"; import { getActivePlan } from "@letsgo/pricing"; +import { LoadingPlaceholder } from "components/LoadingPlaceholder"; function ResolveTenantForNewPlan({ params }: { params: { planId: string } }) { const router = useRouter(); @@ -20,7 +21,7 @@ function ResolveTenantForNewPlan({ params }: { params: { planId: string } }) { if (error) throw error; if (!getActivePlan(params.planId)) return notFound(); - return
Loading...
; + return ; } export default ResolveTenantForNewPlan; diff --git a/apps/web/src/app/(dashboard)/manage/page.tsx b/apps/web/src/app/(dashboard)/manage/page.tsx index 45b171a..bf910d6 100644 --- a/apps/web/src/app/(dashboard)/manage/page.tsx +++ b/apps/web/src/app/(dashboard)/manage/page.tsx @@ -2,7 +2,7 @@ import { useEffect } from "react"; import { useRouter } from "next/navigation"; -import { useTenant } from "../../../components/TenantProvider"; +import { useTenant } from "components/TenantProvider"; function ResolveTenant() { const router = useRouter(); @@ -10,13 +10,13 @@ function ResolveTenant() { useEffect(() => { if (currentTenant) { - router.replace(`/manage/${currentTenant.tenantId}/settings`); + router.replace(`/manage/${currentTenant.tenantId}/dashboard`); } }, [currentTenant, router]); if (error) throw error; - return
Loading...
; + return
; } export default ResolveTenant; diff --git a/apps/web/src/app/(dashboard)/manage/profile/page.tsx b/apps/web/src/app/(dashboard)/manage/profile/page.tsx new file mode 100644 index 0000000..2b3a662 --- /dev/null +++ b/apps/web/src/app/(dashboard)/manage/profile/page.tsx @@ -0,0 +1,5 @@ +"use client"; + +import { Profile } from "components/Profile"; + +export default Profile; diff --git a/apps/web/src/app/(dashboard)/manage/settings/page.tsx b/apps/web/src/app/(dashboard)/manage/settings/page.tsx deleted file mode 100644 index 41fdcd7..0000000 --- a/apps/web/src/app/(dashboard)/manage/settings/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import { Me } from "../../../../components/Me"; -import { User } from "../../../../components/User"; - -export default function Profile() { - return ( -
-

Your user profile:

- -

Response from HTTP GET /api/proxy/v1/me:

- -
- ); -} diff --git a/apps/web/src/app/(site)/contact/page.tsx b/apps/web/src/app/(site)/contact/page.tsx index 1e556f7..1d23ceb 100644 --- a/apps/web/src/app/(site)/contact/page.tsx +++ b/apps/web/src/app/(site)/contact/page.tsx @@ -1,105 +1,5 @@ "use client"; -import { useUser } from "@auth0/nextjs-auth0/client"; -import { useEffect, useState } from "react"; -import { useSearchParams } from "next/navigation"; -import { useTenant } from "../../../components/TenantProvider"; -import { useApiMutate } from "../../../components/common-client"; -import { ContactMessagePayload } from "@letsgo/types"; +import { Contact } from "components/Contact"; -interface ContactParams { - email: string; - name: string; - message: string; -} - -export default function Contact() { - const query = useSearchParams(); - const { isLoading: isUserLoading, user } = useUser(); - const { currentTenant } = useTenant(); - const [submitted, setSubmitted] = useState(false); - const [params, setParams] = useState({ - email: "", - name: "", - message: "", - }); - const { - isMutating: isSubmitting, - error: errorSubmiting, - trigger: submitContact, - } = useApiMutate({ - path: `/v1/contact`, - method: "POST", - unauthenticated: true, - afterSuccess: async () => { - setSubmitted(true); - }, - }); - - useEffect(() => { - if (user) { - setParams({ - email: user.email || "", - name: user.name || "", - message: "", - }); - } - }, [user]); - - if (errorSubmiting) throw errorSubmiting; - if (isUserLoading) return
Loading...
; - if (submitted) return
Thank you for your message!
; - if (isSubmitting) return
Submitting...
; - - const handleSubmit = () => { - const payload: ContactMessagePayload = { - ...params, - query: Object.fromEntries(query.entries()), - tenantId: currentTenant?.tenantId, - identityId: user?.identityId as string, - timestamp: new Date().toISOString(), - }; - submitContact(payload); - }; - - return ( -
-

Contact Us

-
-
Name:
- setParams({ ...params, name: e.target.value })} - /> -

- Email: - setParams({ ...params, email: e.target.value })} - /> -

- Message: -