mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-13 12:55:19 +00:00
Refined onboarding flow (#202)
This commit is contained in:
parent
a79c162d9c
commit
fee0767981
52 changed files with 1360 additions and 665 deletions
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Org" ADD COLUMN "isOnboarded" BOOLEAN NOT NULL DEFAULT false;
|
||||||
|
|
@ -120,10 +120,11 @@ model Org {
|
||||||
connections Connection[]
|
connections Connection[]
|
||||||
repos Repo[]
|
repos Repo[]
|
||||||
secrets Secret[]
|
secrets Secret[]
|
||||||
|
isOnboarded Boolean @default(false)
|
||||||
|
|
||||||
stripeCustomerId String?
|
stripeCustomerId String?
|
||||||
stripeSubscriptionStatus StripeSubscriptionStatus?
|
stripeSubscriptionStatus StripeSubscriptionStatus?
|
||||||
stripeLastUpdatedAt DateTime?
|
stripeLastUpdatedAt DateTime?
|
||||||
|
|
||||||
/// List of pending invites to this organization
|
/// List of pending invites to this organization
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
@ -165,14 +166,14 @@ model Secret {
|
||||||
|
|
||||||
// @see : https://authjs.dev/concepts/database-models#user
|
// @see : https://authjs.dev/concepts/database-models#user
|
||||||
model User {
|
model User {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
name String?
|
name String?
|
||||||
email String? @unique
|
email String? @unique
|
||||||
hashedPassword String?
|
hashedPassword String?
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
image String?
|
image String?
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
orgs UserToOrg[]
|
orgs UserToOrg[]
|
||||||
|
|
||||||
/// List of pending invites that the user has created
|
/// List of pending invites that the user has created
|
||||||
invites Invite[]
|
invites Invite[]
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,8 @@
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"dev:emails": "email dev --dir ./src/emails"
|
"dev:emails": "email dev --dir ./src/emails",
|
||||||
|
"stripe:listen": "stripe listen --forward-to http://localhost:3000/api/stripe"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@auth/prisma-adapter": "^2.7.4",
|
"@auth/prisma-adapter": "^2.7.4",
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,15 @@ import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||||
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { encrypt } from "@sourcebot/crypto"
|
import { encrypt } from "@sourcebot/crypto"
|
||||||
import { getConnection, getLinkedRepos } from "./data/connection";
|
import { getConnection, getLinkedRepos } from "./data/connection";
|
||||||
import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus } from "@sourcebot/db";
|
import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||||
import { headers } from "next/headers"
|
import { headers } from "next/headers"
|
||||||
import { getStripe } from "@/lib/stripe"
|
import { getStripe } from "@/lib/stripe"
|
||||||
import { getUser } from "@/data/user";
|
import { getUser } from "@/data/user";
|
||||||
import { Session } from "next-auth";
|
import { Session } from "next-auth";
|
||||||
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
|
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
|
||||||
import { StripeSubscriptionStatus } from "@sourcebot/db";
|
|
||||||
import Stripe from "stripe";
|
import Stripe from "stripe";
|
||||||
import { SyncStatusMetadataSchema, type NotFoundData } from "@/lib/syncStatusMetadataSchema";
|
import { OnboardingSteps } from "./lib/constants";
|
||||||
|
|
||||||
const ajv = new Ajv({
|
const ajv = new Ajv({
|
||||||
validateFormats: false,
|
validateFormats: false,
|
||||||
});
|
});
|
||||||
|
|
@ -76,7 +76,7 @@ export const withOrgMembership = async <T>(session: Session, domain: string, fn:
|
||||||
message: "You do not have sufficient permissions to perform this action.",
|
message: "You do not have sufficient permissions to perform this action.",
|
||||||
} satisfies ServiceError;
|
} satisfies ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
return fn({
|
return fn({
|
||||||
orgId: org.id,
|
orgId: org.id,
|
||||||
userRole: membership.role,
|
userRole: membership.role,
|
||||||
|
|
@ -88,15 +88,12 @@ export const isAuthed = async () => {
|
||||||
return session != null;
|
return session != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createOrg = (name: string, domain: string, stripeCustomerId?: string): Promise<{ id: number } | ServiceError> =>
|
export const createOrg = (name: string, domain: string): Promise<{ id: number } | ServiceError> =>
|
||||||
withAuth(async (session) => {
|
withAuth(async (session) => {
|
||||||
const org = await prisma.org.create({
|
const org = await prisma.org.create({
|
||||||
data: {
|
data: {
|
||||||
name,
|
name,
|
||||||
domain,
|
domain,
|
||||||
stripeCustomerId,
|
|
||||||
stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE,
|
|
||||||
stripeLastUpdatedAt: new Date(),
|
|
||||||
members: {
|
members: {
|
||||||
create: {
|
create: {
|
||||||
role: "OWNER",
|
role: "OWNER",
|
||||||
|
|
@ -115,6 +112,53 @@ export const createOrg = (name: string, domain: string, stripeCustomerId?: strin
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const completeOnboarding = async (stripeCheckoutSessionId: string, domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
withAuth((session) =>
|
||||||
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
|
const org = await prisma.org.findUnique({
|
||||||
|
where: { id: orgId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripe();
|
||||||
|
const stripeSession = await stripe.checkout.sessions.retrieve(stripeCheckoutSessionId);
|
||||||
|
const stripeCustomerId = stripeSession.customer as string;
|
||||||
|
|
||||||
|
// Catch the case where the customer ID doesn't match the org's customer ID
|
||||||
|
if (org.stripeCustomerId !== stripeCustomerId) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||||
|
message: "Invalid Stripe customer ID",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stripeSession.payment_status !== 'paid') {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.BAD_REQUEST,
|
||||||
|
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||||
|
message: "Payment failed",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.org.update({
|
||||||
|
where: { id: orgId },
|
||||||
|
data: {
|
||||||
|
isOnboarded: true,
|
||||||
|
stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE,
|
||||||
|
stripeLastUpdatedAt: new Date(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> =>
|
export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
|
|
@ -436,7 +480,7 @@ export const getCurrentUserRole = async (domain: string): Promise<OrgRole | Serv
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async ({ userRole }) => {
|
withOrgMembership(session, domain, async ({ userRole }) => {
|
||||||
return userRole;
|
return userRole;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> =>
|
export const createInvites = async (emails: string[], domain: string): Promise<{ success: boolean } | ServiceError> =>
|
||||||
|
|
@ -491,7 +535,7 @@ export const createInvites = async (emails: string[], domain: string): Promise<{
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -539,12 +583,12 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
|
||||||
if (!org) {
|
if (!org) {
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Incrememnt the seat count
|
// @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
|
||||||
if (org.stripeCustomerId) {
|
const subscription = await _fetchSubscriptionForOrg(org.id, tx);
|
||||||
const subscription = await fetchSubscription(org.domain);
|
if (subscription) {
|
||||||
if (isServiceError(subscription)) {
|
if (isServiceError(subscription)) {
|
||||||
throw orgInvalidSubscription();
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSeatCount = subscription.items.data[0].quantity;
|
const existingSeatCount = subscription.items.data[0].quantity;
|
||||||
|
|
@ -740,57 +784,100 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
|
||||||
return parsedConfig;
|
return parsedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setupInitialStripeCustomer = async (name: string, domain: string) =>
|
export const createOnboardingStripeCheckoutSession = async (domain: string) =>
|
||||||
withAuth(async (session) => {
|
withAuth(async (session) =>
|
||||||
const user = await getUser(session.user.id);
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
if (!user) {
|
const org = await prisma.org.findUnique({
|
||||||
return "";
|
where: {
|
||||||
}
|
id: orgId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const stripe = getStripe();
|
if (!org) {
|
||||||
const origin = (await headers()).get('origin')
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
// @nocheckin
|
const user = await getUser(session.user.id);
|
||||||
const test_clock = await stripe.testHelpers.testClocks.create({
|
if (!user) {
|
||||||
frozen_time: Math.floor(Date.now() / 1000)
|
return notFound();
|
||||||
})
|
}
|
||||||
|
|
||||||
const customer = await stripe.customers.create({
|
const stripe = getStripe();
|
||||||
name: user.name!,
|
const origin = (await headers()).get('origin');
|
||||||
email: user.email!,
|
|
||||||
test_clock: test_clock.id
|
|
||||||
})
|
|
||||||
|
|
||||||
const prices = await stripe.prices.list({
|
// @nocheckin
|
||||||
product: STRIPE_PRODUCT_ID,
|
const test_clock = await stripe.testHelpers.testClocks.create({
|
||||||
expand: ['data.product'],
|
frozen_time: Math.floor(Date.now() / 1000)
|
||||||
});
|
});
|
||||||
const stripeSession = await stripe.checkout.sessions.create({
|
|
||||||
ui_mode: 'embedded',
|
// Use the existing customer if it exists, otherwise create a new one.
|
||||||
customer: customer.id,
|
const customerId = await (async () => {
|
||||||
line_items: [
|
if (org.stripeCustomerId) {
|
||||||
{
|
return org.stripeCustomerId;
|
||||||
price: prices.data[0].id,
|
|
||||||
quantity: 1
|
|
||||||
}
|
}
|
||||||
],
|
|
||||||
mode: 'subscription',
|
const customer = await stripe.customers.create({
|
||||||
subscription_data: {
|
name: org.name,
|
||||||
trial_period_days: 7,
|
email: user.email ?? undefined,
|
||||||
trial_settings: {
|
test_clock: test_clock.id,
|
||||||
end_behavior: {
|
description: `Created by ${user.email} on ${domain} (id: ${org.id})`,
|
||||||
missing_payment_method: 'cancel',
|
});
|
||||||
|
|
||||||
|
await prisma.org.update({
|
||||||
|
where: {
|
||||||
|
id: org.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
stripeCustomerId: customer.id,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return customer.id;
|
||||||
|
})();
|
||||||
|
|
||||||
|
|
||||||
|
const prices = await stripe.prices.list({
|
||||||
|
product: STRIPE_PRODUCT_ID,
|
||||||
|
expand: ['data.product'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeSession = await stripe.checkout.sessions.create({
|
||||||
|
customer: customerId,
|
||||||
|
line_items: [
|
||||||
|
{
|
||||||
|
price: prices.data[0].id,
|
||||||
|
quantity: 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
mode: 'subscription',
|
||||||
|
subscription_data: {
|
||||||
|
trial_period_days: 7,
|
||||||
|
trial_settings: {
|
||||||
|
end_behavior: {
|
||||||
|
missing_payment_method: 'cancel',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
payment_method_collection: 'if_required',
|
||||||
payment_method_collection: 'if_required',
|
success_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Complete}&stripe_session_id={CHECKOUT_SESSION_ID}`,
|
||||||
return_url: `${origin}/onboard/complete?session_id={CHECKOUT_SESSION_ID}&org_name=${name}&org_domain=${domain}`,
|
cancel_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Checkout}`,
|
||||||
})
|
});
|
||||||
|
|
||||||
return stripeSession.client_secret!;
|
if (!stripeSession.url) {
|
||||||
});
|
return {
|
||||||
|
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||||
|
message: "Failed to create checkout session",
|
||||||
|
} satisfies ServiceError;
|
||||||
|
}
|
||||||
|
|
||||||
export const getSubscriptionCheckoutRedirect = async (domain: string) =>
|
return {
|
||||||
|
url: stripeSession.url,
|
||||||
|
}
|
||||||
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
|
);
|
||||||
|
|
||||||
|
export const createStripeCheckoutSession = async (domain: string) =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
const org = await prisma.org.findUnique({
|
const org = await prisma.org.findUnique({
|
||||||
|
|
@ -820,35 +907,36 @@ export const getSubscriptionCheckoutRedirect = async (domain: string) =>
|
||||||
expand: ['data.product'],
|
expand: ['data.product'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const createNewSubscription = async () => {
|
const stripeSession = await stripe.checkout.sessions.create({
|
||||||
const stripeSession = await stripe.checkout.sessions.create({
|
customer: org.stripeCustomerId as string,
|
||||||
customer: org.stripeCustomerId as string,
|
payment_method_types: ['card'],
|
||||||
payment_method_types: ['card'],
|
line_items: [
|
||||||
line_items: [
|
{
|
||||||
{
|
price: prices.data[0].id,
|
||||||
price: prices.data[0].id,
|
quantity: numOrgMembers
|
||||||
quantity: numOrgMembers
|
}
|
||||||
}
|
],
|
||||||
],
|
mode: 'subscription',
|
||||||
mode: 'subscription',
|
payment_method_collection: 'always',
|
||||||
payment_method_collection: 'always',
|
success_url: `${origin}/${domain}/settings/billing`,
|
||||||
success_url: `${origin}/${domain}/settings/billing`,
|
cancel_url: `${origin}/${domain}`,
|
||||||
cancel_url: `${origin}/${domain}`,
|
});
|
||||||
});
|
|
||||||
|
|
||||||
return stripeSession.url;
|
if (!stripeSession.url) {
|
||||||
|
return {
|
||||||
|
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
|
||||||
|
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
|
||||||
|
message: "Failed to create checkout session",
|
||||||
|
} satisfies ServiceError;
|
||||||
}
|
}
|
||||||
|
|
||||||
const newSubscriptionUrl = await createNewSubscription();
|
return {
|
||||||
return newSubscriptionUrl;
|
url: stripeSession.url,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
export async function fetchStripeSession(sessionId: string) {
|
|
||||||
const stripe = getStripe();
|
|
||||||
const stripeSession = await stripe.checkout.sessions.retrieve(sessionId);
|
|
||||||
return stripeSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
|
export const getCustomerPortalSessionLink = async (domain: string): Promise<string | ServiceError> =>
|
||||||
withAuth((session) =>
|
withAuth((session) =>
|
||||||
|
|
@ -874,29 +962,39 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
|
||||||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | ServiceError> =>
|
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | null | ServiceError> =>
|
||||||
withAuth(async () => {
|
withAuth(async (session) =>
|
||||||
const org = await prisma.org.findUnique({
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
where: {
|
return _fetchSubscriptionForOrg(orgId, prisma);
|
||||||
domain,
|
})
|
||||||
},
|
);
|
||||||
});
|
|
||||||
|
|
||||||
if (!org || !org.stripeCustomerId) {
|
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => {
|
||||||
return notFound();
|
const org = await prisma.org.findUnique({
|
||||||
}
|
where: {
|
||||||
|
id: orgId,
|
||||||
const stripe = getStripe();
|
},
|
||||||
const subscriptions = await stripe.subscriptions.list({
|
|
||||||
customer: org.stripeCustomerId
|
|
||||||
});
|
|
||||||
|
|
||||||
if (subscriptions.data.length === 0) {
|
|
||||||
return notFound();
|
|
||||||
}
|
|
||||||
return subscriptions.data[0];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!org.stripeCustomerId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripe = getStripe();
|
||||||
|
const subscriptions = await stripe.subscriptions.list({
|
||||||
|
customer: org.stripeCustomerId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subscriptions.data.length === 0) {
|
||||||
|
return orgInvalidSubscription();
|
||||||
|
}
|
||||||
|
return subscriptions.data[0];
|
||||||
|
}
|
||||||
|
|
||||||
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
|
export const getSubscriptionBillingEmail = async (domain: string): Promise<string | ServiceError> =>
|
||||||
withAuth(async (session) =>
|
withAuth(async (session) =>
|
||||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||||
|
|
@ -990,10 +1088,10 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org.stripeCustomerId) {
|
const subscription = await fetchSubscription(domain);
|
||||||
const subscription = await fetchSubscription(domain);
|
if (subscription) {
|
||||||
if (isServiceError(subscription)) {
|
if (isServiceError(subscription)) {
|
||||||
return orgInvalidSubscription();
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSeatCount = subscription.items.data[0].quantity;
|
const existingSeatCount = subscription.items.data[0].quantity;
|
||||||
|
|
@ -1045,10 +1143,10 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
|
||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (org.stripeCustomerId) {
|
const subscription = await fetchSubscription(domain);
|
||||||
const subscription = await fetchSubscription(domain);
|
if (subscription) {
|
||||||
if (isServiceError(subscription)) {
|
if (isServiceError(subscription)) {
|
||||||
return orgInvalidSubscription();
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingSeatCount = subscription.items.data[0].quantity;
|
const existingSeatCount = subscription.items.data[0].quantity;
|
||||||
|
|
@ -1084,7 +1182,11 @@ export const getSubscriptionData = async (domain: string) =>
|
||||||
withOrgMembership(session, domain, async () => {
|
withOrgMembership(session, domain, async () => {
|
||||||
const subscription = await fetchSubscription(domain);
|
const subscription = await fetchSubscription(domain);
|
||||||
if (isServiceError(subscription)) {
|
if (isServiceError(subscription)) {
|
||||||
return orgInvalidSubscription();
|
return subscription;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
|
||||||
|
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
||||||
|
import { gerritQuickActions } from "../../connections/quickActions";
|
||||||
|
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
|
||||||
|
|
||||||
|
interface GerritConnectionCreationFormProps {
|
||||||
|
onCreated?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => {
|
||||||
|
const defaultConfig: GerritConnectionConfig = {
|
||||||
|
type: 'gerrit',
|
||||||
|
url: "https://gerrit.example.com"
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedConnectionCreationForm<GerritConnectionConfig>
|
||||||
|
type="gerrit"
|
||||||
|
title="Create a Gerrit connection"
|
||||||
|
defaultValues={{
|
||||||
|
config: JSON.stringify(defaultConfig, null, 2),
|
||||||
|
name: 'my-gerrit-connection',
|
||||||
|
}}
|
||||||
|
schema={gerritSchema}
|
||||||
|
quickActions={gerritQuickActions}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
||||||
|
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
||||||
|
import { giteaQuickActions } from "../../connections/quickActions";
|
||||||
|
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
|
||||||
|
|
||||||
|
interface GiteaConnectionCreationFormProps {
|
||||||
|
onCreated?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => {
|
||||||
|
const defaultConfig: GiteaConnectionConfig = {
|
||||||
|
type: 'gitea',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedConnectionCreationForm<GiteaConnectionConfig>
|
||||||
|
type="gitea"
|
||||||
|
title="Create a Gitea connection"
|
||||||
|
defaultValues={{
|
||||||
|
config: JSON.stringify(defaultConfig, null, 2),
|
||||||
|
name: 'my-gitea-connection',
|
||||||
|
}}
|
||||||
|
schema={giteaSchema}
|
||||||
|
quickActions={giteaQuickActions}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||||
|
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
||||||
|
import { githubQuickActions } from "../../connections/quickActions";
|
||||||
|
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
|
||||||
|
|
||||||
|
interface GitHubConnectionCreationFormProps {
|
||||||
|
onCreated?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => {
|
||||||
|
const defaultConfig: GithubConnectionConfig = {
|
||||||
|
type: 'github',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedConnectionCreationForm<GithubConnectionConfig>
|
||||||
|
type="github"
|
||||||
|
title="Create a GitHub connection"
|
||||||
|
defaultValues={{
|
||||||
|
config: JSON.stringify(defaultConfig, null, 2),
|
||||||
|
name: 'my-github-connection',
|
||||||
|
}}
|
||||||
|
schema={githubSchema}
|
||||||
|
quickActions={githubQuickActions}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
||||||
|
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
||||||
|
import { gitlabQuickActions } from "../../connections/quickActions";
|
||||||
|
import SharedConnectionCreationForm from "./sharedConnectionCreationForm";
|
||||||
|
|
||||||
|
interface GitLabConnectionCreationFormProps {
|
||||||
|
onCreated?: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => {
|
||||||
|
const defaultConfig: GitlabConnectionConfig = {
|
||||||
|
type: 'gitlab',
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SharedConnectionCreationForm<GitlabConnectionConfig>
|
||||||
|
type="gitlab"
|
||||||
|
title="Create a GitLab connection"
|
||||||
|
defaultValues={{
|
||||||
|
config: JSON.stringify(defaultConfig, null, 2),
|
||||||
|
name: 'my-gitlab-connection',
|
||||||
|
}}
|
||||||
|
schema={gitlabSchema}
|
||||||
|
quickActions={gitlabQuickActions}
|
||||||
|
onCreated={onCreated}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
export { GitHubConnectionCreationForm } from "./githubConnectionCreationForm";
|
||||||
|
export { GitLabConnectionCreationForm } from "./gitlabConnectionCreationForm";
|
||||||
|
export { GiteaConnectionCreationForm } from "./giteaConnectionCreationForm";
|
||||||
|
export { GerritConnectionCreationForm } from "./gerritConnectionCreationForm";
|
||||||
|
|
@ -9,16 +9,17 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Schema } from "ajv";
|
import { Schema } from "ajv";
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { useCallback, useMemo } from "react";
|
import { useCallback, useMemo } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConfigEditor, QuickActionFn } from "../../../components/configEditor";
|
import { ConfigEditor, QuickActionFn } from "../configEditor";
|
||||||
import { useDomain } from "@/hooks/useDomain";
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
interface ConnectionCreationForm<T> {
|
interface SharedConnectionCreationFormProps<T> {
|
||||||
type: 'github' | 'gitlab' | 'gitea' | 'gerrit';
|
type: 'github' | 'gitlab' | 'gitea' | 'gerrit';
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -30,18 +31,21 @@ interface ConnectionCreationForm<T> {
|
||||||
name: string;
|
name: string;
|
||||||
fn: QuickActionFn<T>;
|
fn: QuickActionFn<T>;
|
||||||
}[],
|
}[],
|
||||||
|
className?: string;
|
||||||
|
onCreated?: (id: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ConnectionCreationForm<T>({
|
export default function SharedConnectionCreationForm<T>({
|
||||||
type,
|
type,
|
||||||
defaultValues,
|
defaultValues,
|
||||||
title,
|
title,
|
||||||
schema,
|
schema,
|
||||||
quickActions,
|
quickActions,
|
||||||
}: ConnectionCreationForm<T>) {
|
className,
|
||||||
|
onCreated,
|
||||||
|
}: SharedConnectionCreationFormProps<T>) {
|
||||||
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
|
||||||
const domain = useDomain();
|
const domain = useDomain();
|
||||||
|
|
||||||
const formSchema = useMemo(() => {
|
const formSchema = useMemo(() => {
|
||||||
|
|
@ -55,26 +59,24 @@ export default function ConnectionCreationForm<T>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: defaultValues,
|
defaultValues: defaultValues,
|
||||||
});
|
});
|
||||||
|
const { isSubmitting } = form.formState;
|
||||||
|
|
||||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
|
||||||
createConnection(data.name, type, data.config, domain)
|
const response = await createConnection(data.name, type, data.config, domain);
|
||||||
.then((response) => {
|
if (isServiceError(response)) {
|
||||||
if (isServiceError(response)) {
|
toast({
|
||||||
toast({
|
description: `❌ Failed to create connection. Reason: ${response.message}`
|
||||||
description: `❌ Failed to create connection. Reason: ${response.message}`
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
toast({
|
|
||||||
description: `✅ Connection created successfully.`
|
|
||||||
});
|
|
||||||
router.push(`/${domain}/connections`);
|
|
||||||
router.refresh();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}, [domain, router, toast, type]);
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Connection created successfully.`
|
||||||
|
});
|
||||||
|
onCreated?.(response.id);
|
||||||
|
}
|
||||||
|
}, [domain, toast, type, onCreated]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6">
|
<div className={cn("flex flex-col max-w-3xl mx-auto bg-background border rounded-lg p-6", className)}>
|
||||||
<div className="flex flex-row items-center gap-3 mb-6">
|
<div className="flex flex-row items-center gap-3 mb-6">
|
||||||
<ConnectionIcon
|
<ConnectionIcon
|
||||||
type={type}
|
type={type}
|
||||||
|
|
@ -128,7 +130,14 @@ export default function ConnectionCreationForm<T>({
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button className="mt-5" type="submit">Submit</Button>
|
<Button
|
||||||
|
className="mt-5"
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && <Loader2 className="animate-spin w-4 h-4 mr-2" />}
|
||||||
|
Submit
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -89,7 +89,7 @@ export const NavigationMenu = async ({
|
||||||
<ProgressNavIndicator />
|
<ProgressNavIndicator />
|
||||||
<WarningNavIndicator />
|
<WarningNavIndicator />
|
||||||
<ErrorNavIndicator />
|
<ErrorNavIndicator />
|
||||||
{!isServiceError(subscription) && subscription.status === "trialing" && (
|
{!isServiceError(subscription) && subscription && subscription.status === "trialing" && (
|
||||||
<Link href={`/${domain}/settings/billing`}>
|
<Link href={`/${domain}/settings/billing`}>
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
|
||||||
<span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>
|
<span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>
|
||||||
|
|
|
||||||
31
packages/web/src/app/[domain]/components/onboardGuard.tsx
Normal file
31
packages/web/src/app/[domain]/components/onboardGuard.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Redirect } from "@/app/components/redirect";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface OnboardGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnboardGuard = ({ children }: OnboardGuardProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (!pathname.endsWith('/onboard')) {
|
||||||
|
return (
|
||||||
|
<Redirect
|
||||||
|
to={`/${domain}/onboard`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}, [domain, children, pathname]);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
import { CaretSortIcon, CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { OrgIcon } from "./orgIcon";
|
import { OrgIcon } from "./orgIcon";
|
||||||
|
|
@ -108,6 +109,20 @@ export const OrgSelectorDropdown = ({
|
||||||
</CommandList>
|
</CommandList>
|
||||||
</Command>
|
</Command>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
{searchFilter.length === 0 && (
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="default"
|
||||||
|
className="w-full justify-start gap-1.5 p-2"
|
||||||
|
onClick={() => router.push("/onboard")}
|
||||||
|
>
|
||||||
|
<PlusCircledIcon className="h-5 w-5 text-muted-foreground" />
|
||||||
|
Create new organization
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { getSubscriptionCheckoutRedirect } from "@/actions"
|
|
||||||
import { isServiceError } from "@/lib/utils"
|
|
||||||
|
|
||||||
|
|
||||||
export function CheckoutButton({ domain }: { domain: string }) {
|
|
||||||
const redirectToCheckout = async () => {
|
|
||||||
const redirectUrl = await getSubscriptionCheckoutRedirect(domain)
|
|
||||||
|
|
||||||
if (isServiceError(redirectUrl)) {
|
|
||||||
console.error("Failed to create checkout session")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.location.href = redirectUrl!;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button className="w-full" onClick={redirectToCheckout}>Renew Membership</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
|
|
||||||
export function EnterpriseContactUsButton() {
|
|
||||||
const handleContactUs = () => {
|
|
||||||
window.location.href = "mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button className="w-full" onClick={handleContactUs}>
|
|
||||||
Contact Us
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
|
||||||
import { Check } from "lucide-react"
|
|
||||||
import { EnterpriseContactUsButton } from "./enterpriseContactUsButton"
|
|
||||||
import { CheckoutButton } from "./checkoutButton"
|
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
|
||||||
|
|
||||||
const teamFeatures = [
|
|
||||||
"Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported",
|
|
||||||
"Public and private repos supported",
|
|
||||||
"Create sharable links to code snippets",
|
|
||||||
"9x5 email support team@sourcebot.dev",
|
|
||||||
]
|
|
||||||
|
|
||||||
const enterpriseFeatures = [
|
|
||||||
"All Team features",
|
|
||||||
"Dedicated Slack support channel",
|
|
||||||
"Single tenant deployment",
|
|
||||||
"Advanced security features",
|
|
||||||
]
|
|
||||||
|
|
||||||
export async function PaywallCard({ domain }: { domain: string }) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-4xl mx-auto px-4 py-8">
|
|
||||||
<div className="max-h-44 w-auto mb-4 flex justify-center">
|
|
||||||
<SourcebotLogo
|
|
||||||
className="h-18 md:h-40"
|
|
||||||
size="large"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h2 className="text-3xl font-bold text-center mb-8 text-primary">
|
|
||||||
Your subscription has expired.
|
|
||||||
</h2>
|
|
||||||
<div className="grid gap-8 md:grid-cols-2">
|
|
||||||
<Card className="border-2 border-primary/20 shadow-lg transition-all duration-300 hover:shadow-xl hover:border-primary/50 flex flex-col">
|
|
||||||
<CardHeader className="space-y-1">
|
|
||||||
<CardTitle className="text-2xl font-bold text-primary">Team</CardTitle>
|
|
||||||
<CardDescription className="text-base">For professional developers and small teams</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-grow">
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-4xl font-bold text-primary">$10</p>
|
|
||||||
<p className="text-sm text-muted-foreground">per user / month</p>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{teamFeatures.map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-center">
|
|
||||||
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
|
|
||||||
<span>{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<CheckoutButton domain={domain} />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
<Card className="border-2 border-primary/20 shadow-lg transition-all duration-300 hover:shadow-xl hover:border-primary/50 flex flex-col">
|
|
||||||
<CardHeader className="space-y-1">
|
|
||||||
<CardTitle className="text-2xl font-bold text-primary">Enterprise</CardTitle>
|
|
||||||
<CardDescription className="text-base">For large organizations with custom needs</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="flex-grow">
|
|
||||||
<div className="mb-4">
|
|
||||||
<p className="text-4xl font-bold text-primary">Custom</p>
|
|
||||||
<p className="text-sm text-muted-foreground">tailored to your needs</p>
|
|
||||||
</div>
|
|
||||||
<ul className="space-y-3">
|
|
||||||
{enterpriseFeatures.map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-center">
|
|
||||||
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
|
|
||||||
<span>{feature}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</CardContent>
|
|
||||||
<CardFooter>
|
|
||||||
<EnterpriseContactUsButton />
|
|
||||||
</CardFooter>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
31
packages/web/src/app/[domain]/components/upgradeGuard.tsx
Normal file
31
packages/web/src/app/[domain]/components/upgradeGuard.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Redirect } from "@/app/components/redirect";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { useMemo } from "react";
|
||||||
|
|
||||||
|
interface UpgradeGuardProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpgradeGuard = ({ children }: UpgradeGuardProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
const content = useMemo(() => {
|
||||||
|
if (!pathname.endsWith('/upgrade')) {
|
||||||
|
return (
|
||||||
|
<Redirect
|
||||||
|
to={`/${domain}/upgrade`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
}, [domain, children, pathname]);
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Loader2 } from "lucide-react";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ConfigEditor, QuickAction } from "../../components/configEditor";
|
import { ConfigEditor, QuickAction } from "../../../components/configEditor";
|
||||||
import { createZodConnectionConfigValidator } from "../../utils";
|
import { createZodConnectionConfigValidator } from "../../utils";
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
||||||
|
|
|
||||||
|
|
@ -1,115 +1,38 @@
|
||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { gerritQuickActions, giteaQuickActions, githubQuickActions, gitlabQuickActions } from "../../quickActions";
|
|
||||||
import ConnectionCreationForm from "./components/connectionCreationForm";
|
|
||||||
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
|
||||||
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
|
|
||||||
import { gitlabSchema } from "@sourcebot/schemas/v3/gitlab.schema";
|
|
||||||
import { githubSchema } from "@sourcebot/schemas/v3/github.schema";
|
|
||||||
import { giteaSchema } from "@sourcebot/schemas/v3/gitea.schema";
|
|
||||||
import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
|
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
GitHubConnectionCreationForm,
|
||||||
|
GitLabConnectionCreationForm,
|
||||||
|
GiteaConnectionCreationForm,
|
||||||
|
GerritConnectionCreationForm
|
||||||
|
} from "@/app/[domain]/components/connectionCreationForms";
|
||||||
|
import { useCallback } from "react";
|
||||||
export default function NewConnectionPage({
|
export default function NewConnectionPage({
|
||||||
params
|
params
|
||||||
}: { params: { type: string } }) {
|
}: { params: { type: string } }) {
|
||||||
const { type } = params;
|
const { type } = params;
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
const onCreated = useCallback(() => {
|
||||||
|
router.push('/connections');
|
||||||
|
}, [router]);
|
||||||
|
|
||||||
if (type === 'github') {
|
if (type === 'github') {
|
||||||
return <GitHubCreationForm />;
|
return <GitHubConnectionCreationForm onCreated={onCreated} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'gitlab') {
|
if (type === 'gitlab') {
|
||||||
return <GitLabCreationForm />;
|
return <GitLabConnectionCreationForm onCreated={onCreated} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'gitea') {
|
if (type === 'gitea') {
|
||||||
return <GiteaCreationForm />;
|
return <GiteaConnectionCreationForm onCreated={onCreated} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === 'gerrit') {
|
if (type === 'gerrit') {
|
||||||
return <GerritCreationForm />;
|
return <GerritConnectionCreationForm onCreated={onCreated} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push('/connections');
|
router.push('/connections');
|
||||||
}
|
}
|
||||||
|
|
||||||
const GitLabCreationForm = () => {
|
|
||||||
const defaultConfig: GitlabConnectionConfig = {
|
|
||||||
type: 'gitlab',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConnectionCreationForm<GitlabConnectionConfig>
|
|
||||||
type="gitlab"
|
|
||||||
title="Create a GitLab connection"
|
|
||||||
defaultValues={{
|
|
||||||
config: JSON.stringify(defaultConfig, null, 2),
|
|
||||||
name: 'my-gitlab-connection',
|
|
||||||
}}
|
|
||||||
schema={gitlabSchema}
|
|
||||||
quickActions={gitlabQuickActions}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const GitHubCreationForm = () => {
|
|
||||||
const defaultConfig: GithubConnectionConfig = {
|
|
||||||
type: 'github',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConnectionCreationForm<GithubConnectionConfig>
|
|
||||||
type="github"
|
|
||||||
title="Create a GitHub connection"
|
|
||||||
defaultValues={{
|
|
||||||
config: JSON.stringify(defaultConfig, null, 2),
|
|
||||||
name: 'my-github-connection',
|
|
||||||
}}
|
|
||||||
schema={githubSchema}
|
|
||||||
quickActions={githubQuickActions}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const GiteaCreationForm = () => {
|
|
||||||
const defaultConfig: GiteaConnectionConfig = {
|
|
||||||
type: 'gitea',
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConnectionCreationForm<GiteaConnectionConfig>
|
|
||||||
type="gitea"
|
|
||||||
title="Create a Gitea connection"
|
|
||||||
defaultValues={{
|
|
||||||
config: JSON.stringify(defaultConfig, null, 2),
|
|
||||||
name: 'my-gitea-connection',
|
|
||||||
}}
|
|
||||||
schema={giteaSchema}
|
|
||||||
quickActions={giteaQuickActions}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const GerritCreationForm = () => {
|
|
||||||
const defaultConfig: GerritConnectionConfig = {
|
|
||||||
type: 'gerrit',
|
|
||||||
url: "https://gerrit.example.com"
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConnectionCreationForm<GerritConnectionConfig>
|
|
||||||
type="gerrit"
|
|
||||||
title="Create a Gerrit connection"
|
|
||||||
defaultValues={{
|
|
||||||
config: JSON.stringify(defaultConfig, null, 2),
|
|
||||||
name: 'my-gerrit-connection',
|
|
||||||
}}
|
|
||||||
schema={gerritSchema}
|
|
||||||
quickActions={gerritQuickActions}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"
|
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type"
|
||||||
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
import { GitlabConnectionConfig } from "@sourcebot/schemas/v3/gitlab.type";
|
||||||
import { QuickAction } from "./components/configEditor";
|
import { QuickAction } from "../components/configEditor";
|
||||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
|
||||||
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
|
import { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,10 @@ import { prisma } from "@/prisma";
|
||||||
import { PageNotFound } from "./components/pageNotFound";
|
import { PageNotFound } from "./components/pageNotFound";
|
||||||
import { auth } from "@/auth";
|
import { auth } from "@/auth";
|
||||||
import { getOrgFromDomain } from "@/data/org";
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
import { fetchSubscription } from "@/actions";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
import { isServiceError } from "@/lib/utils";
|
||||||
import { PaywallCard } from "./components/payWall/paywallCard";
|
import { OnboardGuard } from "./components/onboardGuard";
|
||||||
import { NavigationMenu } from "./components/navigationMenu";
|
import { fetchSubscription } from "@/actions";
|
||||||
import { Footer } from "./components/footer";
|
import { UpgradeGuard } from "./components/upgradeGuard";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode,
|
children: React.ReactNode,
|
||||||
|
|
@ -43,14 +42,26 @@ export default async function Layout({
|
||||||
return <PageNotFound />
|
return <PageNotFound />
|
||||||
}
|
}
|
||||||
|
|
||||||
const subscription = await fetchSubscription(domain);
|
if (!org.isOnboarded) {
|
||||||
if (isServiceError(subscription) || (subscription.status !== "active" && subscription.status !== "trialing")) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
<OnboardGuard>
|
||||||
<NavigationMenu domain={domain} />
|
{children}
|
||||||
<PaywallCard domain={domain} />
|
</OnboardGuard>
|
||||||
<Footer />
|
)
|
||||||
</div>
|
}
|
||||||
|
|
||||||
|
const subscription = await fetchSubscription(domain);
|
||||||
|
if (
|
||||||
|
subscription &&
|
||||||
|
(
|
||||||
|
isServiceError(subscription) ||
|
||||||
|
(subscription.status !== "active" && subscription.status !== "trialing")
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<UpgradeGuard>
|
||||||
|
{children}
|
||||||
|
</UpgradeGuard>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createOnboardingStripeCheckoutSession } from "@/actions";
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useNonEmptyQueryParam } from "@/hooks/useNonEmptyQueryParam";
|
||||||
|
import { ErrorCode } from "@/lib/errorCodes";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { Check, Loader2 } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { TEAM_FEATURES } from "@/lib/constants";
|
||||||
|
|
||||||
|
export const Checkout = () => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const errorCode = useNonEmptyQueryParam('errorCode');
|
||||||
|
const errorMessage = useNonEmptyQueryParam('errorMessage');
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (errorCode === ErrorCode.STRIPE_CHECKOUT_ERROR && errorMessage) {
|
||||||
|
toast({
|
||||||
|
description: `⚠️ Stripe checkout failed with error: ${errorMessage}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [errorCode, errorMessage, toast]);
|
||||||
|
|
||||||
|
const onCheckout = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
createOnboardingStripeCheckoutSession(domain)
|
||||||
|
.then((response) => {
|
||||||
|
if (isServiceError(response)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Stripe checkout failed with error: ${response.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
router.push(response.url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [domain, router, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center max-w-md my-auto">
|
||||||
|
<SourcebotLogo
|
||||||
|
className="h-16"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<h1 className="text-2xl font-semibold">Start your 7 day free trial</h1>
|
||||||
|
<p className="text-muted-foreground mt-2">Cancel anytime. No credit card required.</p>
|
||||||
|
<ul className="space-y-4 mb-6 mt-10">
|
||||||
|
{TEAM_FEATURES.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-center">
|
||||||
|
<div className="mr-3 flex-shrink-0">
|
||||||
|
<Check className="h-5 w-5 text-sky-500" />
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-600 dark:text-gray-300">{feature}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<div className="w-full px-16 mt-8">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={onCheckout}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Start free trial
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { completeOnboarding } from "@/actions";
|
||||||
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
interface CompleteOnboardingProps {
|
||||||
|
searchParams: {
|
||||||
|
stripe_session_id?: string;
|
||||||
|
}
|
||||||
|
params: {
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CompleteOnboarding = async ({ searchParams, params: { domain } }: CompleteOnboardingProps) => {
|
||||||
|
if (!searchParams.stripe_session_id) {
|
||||||
|
return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}`);
|
||||||
|
}
|
||||||
|
const { stripe_session_id } = searchParams;
|
||||||
|
|
||||||
|
const response = await completeOnboarding(stripe_session_id, domain);
|
||||||
|
if (isServiceError(response)) {
|
||||||
|
return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect(`/${domain}`);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import Image from "next/image";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { cn, CodeHostType } from "@/lib/utils";
|
||||||
|
import { getCodeHostIcon } from "@/lib/utils";
|
||||||
|
import {
|
||||||
|
GitHubConnectionCreationForm,
|
||||||
|
GitLabConnectionCreationForm,
|
||||||
|
GiteaConnectionCreationForm,
|
||||||
|
GerritConnectionCreationForm
|
||||||
|
} from "@/app/[domain]/components/connectionCreationForms";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
interface ConnectCodeHostProps {
|
||||||
|
nextStep: OnboardingSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ConnectCodeHost = ({ nextStep }: ConnectCodeHostProps) => {
|
||||||
|
const [selectedCodeHost, setSelectedCodeHost] = useState<CodeHostType | null>(null);
|
||||||
|
const router = useRouter();
|
||||||
|
const onCreated = useCallback(() => {
|
||||||
|
router.push(`?step=${nextStep}`);
|
||||||
|
}, [nextStep, router]);
|
||||||
|
|
||||||
|
if (!selectedCodeHost) {
|
||||||
|
return (
|
||||||
|
<CodeHostSelection onSelect={setSelectedCodeHost} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCodeHost === "github") {
|
||||||
|
return (
|
||||||
|
<GitHubConnectionCreationForm onCreated={onCreated} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCodeHost === "gitlab") {
|
||||||
|
return (
|
||||||
|
<GitLabConnectionCreationForm onCreated={onCreated} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCodeHost === "gitea") {
|
||||||
|
return (
|
||||||
|
<GiteaConnectionCreationForm onCreated={onCreated} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCodeHost === "gerrit") {
|
||||||
|
return (
|
||||||
|
<GerritConnectionCreationForm onCreated={onCreated} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeHostSelectionProps {
|
||||||
|
onSelect: (codeHost: CodeHostType) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeHostSelection = ({ onSelect }: CodeHostSelectionProps) => {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-row gap-4">
|
||||||
|
<CodeHostButton
|
||||||
|
name="GitHub"
|
||||||
|
logo={getCodeHostIcon("github")!}
|
||||||
|
onClick={() => onSelect("github")}
|
||||||
|
/>
|
||||||
|
<CodeHostButton
|
||||||
|
name="GitLab"
|
||||||
|
logo={getCodeHostIcon("gitlab")!}
|
||||||
|
onClick={() => onSelect("gitlab")}
|
||||||
|
/>
|
||||||
|
<CodeHostButton
|
||||||
|
name="Gitea"
|
||||||
|
logo={getCodeHostIcon("gitea")!}
|
||||||
|
onClick={() => onSelect("gitea")}
|
||||||
|
/>
|
||||||
|
<CodeHostButton
|
||||||
|
name="Gerrit"
|
||||||
|
logo={getCodeHostIcon("gerrit")!}
|
||||||
|
onClick={() => onSelect("gerrit")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CodeHostButtonProps {
|
||||||
|
name: string;
|
||||||
|
logo: { src: string, className?: string };
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CodeHostButton = ({
|
||||||
|
name,
|
||||||
|
logo,
|
||||||
|
onClick,
|
||||||
|
}: CodeHostButtonProps) => {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className="flex flex-col items-center justify-center p-4 w-24 h-24 cursor-pointer gap-2"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<Image src={logo.src} alt={name} className={cn("w-8 h-8", logo.className)} />
|
||||||
|
<p className="text-sm font-medium">{name}</p>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
123
packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx
Normal file
123
packages/web/src/app/[domain]/onboard/components/inviteTeam.tsx
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { createInvites } from "@/actions";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardFooter } from "@/components/ui/card";
|
||||||
|
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Loader2, PlusCircleIcon } from "lucide-react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { inviteMemberFormSchema } from "../../settings/members/components/inviteMemberCard";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
interface InviteTeamProps {
|
||||||
|
nextStep: OnboardingSteps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
|
||||||
|
resolver: zodResolver(inviteMemberFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
emails: [{ email: "" }]
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addEmailField = useCallback(() => {
|
||||||
|
const emails = form.getValues().emails;
|
||||||
|
form.setValue('emails', [...emails, { email: "" }]);
|
||||||
|
}, [form]);
|
||||||
|
|
||||||
|
const onComplete = useCallback(() => {
|
||||||
|
router.push(`?step=${nextStep}`);
|
||||||
|
}, [nextStep, router]);
|
||||||
|
|
||||||
|
const onSubmit = useCallback(async (data: z.infer<typeof inviteMemberFormSchema>) => {
|
||||||
|
const response = await createInvites(data.emails.map(e => e.email), domain);
|
||||||
|
if (isServiceError(response)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to invite members. Reason: ${response.message}`
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
toast({
|
||||||
|
description: `✅ Successfully invited ${data.emails.length} members`
|
||||||
|
});
|
||||||
|
onComplete();
|
||||||
|
}
|
||||||
|
}, [domain, toast, onComplete]);
|
||||||
|
|
||||||
|
const onSkip = useCallback(() => {
|
||||||
|
onComplete();
|
||||||
|
}, [onComplete]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-12 w-[500px]">
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<FormLabel>Email Address</FormLabel>
|
||||||
|
{form.watch('emails').map((_, index) => (
|
||||||
|
<FormField
|
||||||
|
key={index}
|
||||||
|
control={form.control}
|
||||||
|
name={`emails.${index}.email`}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
placeholder="melissa@example.com"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{form.formState.errors.emails?.root?.message && (
|
||||||
|
<FormMessage>{form.formState.errors.emails.root.message}</FormMessage>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={addEmailField}
|
||||||
|
>
|
||||||
|
<PlusCircleIcon className="w-4 h-4 mr-0.5" />
|
||||||
|
Add more
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
className="mr-2"
|
||||||
|
type="button"
|
||||||
|
onClick={onSkip}
|
||||||
|
>
|
||||||
|
Skip for now
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
type="submit"
|
||||||
|
disabled={form.formState.isSubmitting}
|
||||||
|
>
|
||||||
|
{form.formState.isSubmitting && <Loader2 className="w-4 h-4 mr-0.5 animate-spin" />}
|
||||||
|
Invite
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</Card >
|
||||||
|
)
|
||||||
|
}
|
||||||
90
packages/web/src/app/[domain]/onboard/page.tsx
Normal file
90
packages/web/src/app/[domain]/onboard/page.tsx
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
import { OnboardHeader } from "@/app/onboard/components/onboardHeader";
|
||||||
|
import { getOrgFromDomain } from "@/data/org";
|
||||||
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { ConnectCodeHost } from "./components/connectCodeHost";
|
||||||
|
import { InviteTeam } from "./components/inviteTeam";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { CompleteOnboarding } from "./components/completeOnboarding";
|
||||||
|
import { Checkout } from "./components/checkout";
|
||||||
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
|
|
||||||
|
interface OnboardProps {
|
||||||
|
params: {
|
||||||
|
domain: string
|
||||||
|
},
|
||||||
|
searchParams: {
|
||||||
|
step?: string
|
||||||
|
stripe_session_id?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function Onboard({ params, searchParams }: OnboardProps) {
|
||||||
|
const org = await getOrgFromDomain(params.domain);
|
||||||
|
if (!org) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (org.isOnboarded) {
|
||||||
|
redirect(`/${params.domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = searchParams.step ?? OnboardingSteps.ConnectCodeHost;
|
||||||
|
if (
|
||||||
|
!Object.values(OnboardingSteps)
|
||||||
|
.filter(s => s !== OnboardingSteps.CreateOrg)
|
||||||
|
.map(s => s.toString())
|
||||||
|
.includes(step)
|
||||||
|
) {
|
||||||
|
redirect(`/${params.domain}/onboard?step=${OnboardingSteps.ConnectCodeHost}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastRequiredStep = OnboardingSteps.Checkout;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center p-12 min-h-screen bg-backgroundSecondary relative">
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
|
||||||
|
{step === OnboardingSteps.ConnectCodeHost && (
|
||||||
|
<>
|
||||||
|
<OnboardHeader
|
||||||
|
title="Connect your code host"
|
||||||
|
description="Connect your code host to start searching your code."
|
||||||
|
step={step as OnboardingSteps}
|
||||||
|
/>
|
||||||
|
<ConnectCodeHost
|
||||||
|
nextStep={OnboardingSteps.InviteTeam}
|
||||||
|
/>
|
||||||
|
<Link
|
||||||
|
className="text-sm text-muted-foreground underline cursor-pointer mt-12"
|
||||||
|
href={`?step=${lastRequiredStep}`}
|
||||||
|
>
|
||||||
|
Skip onboarding
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === OnboardingSteps.InviteTeam && (
|
||||||
|
<>
|
||||||
|
<OnboardHeader
|
||||||
|
title="Invite your team"
|
||||||
|
description="Invite your team to get the most out of Sourcebot."
|
||||||
|
step={step as OnboardingSteps}
|
||||||
|
/>
|
||||||
|
<InviteTeam
|
||||||
|
nextStep={lastRequiredStep}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === OnboardingSteps.Checkout && (
|
||||||
|
<>
|
||||||
|
<Checkout />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{step === OnboardingSteps.Complete && (
|
||||||
|
<CompleteOnboarding
|
||||||
|
searchParams={searchParams}
|
||||||
|
params={params}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -83,8 +83,8 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Email address</FormLabel>
|
<FormLabel>Email address</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder={billingEmail}
|
placeholder={billingEmail}
|
||||||
{...field}
|
{...field}
|
||||||
disabled={currentUserRole !== OrgRole.OWNER}
|
disabled={currentUserRole !== OrgRole.OWNER}
|
||||||
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
||||||
|
|
@ -94,14 +94,15 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Button
|
<div className="flex justify-end">
|
||||||
type="submit"
|
<Button
|
||||||
className="w-full"
|
type="submit"
|
||||||
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
|
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
|
||||||
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
||||||
>
|
>
|
||||||
{isLoading ? "Updating..." : "Update Billing Email"}
|
{isLoading ? "Updating..." : "Update Billing Email"}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
|
||||||
|
|
@ -31,13 +31,14 @@ export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole:
|
||||||
|
|
||||||
const isOwner = currentUserRole === OrgRole.OWNER
|
const isOwner = currentUserRole === OrgRole.OWNER
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="flex w-full justify-end">
|
||||||
className="w-full"
|
<Button
|
||||||
onClick={redirectToCustomerPortal}
|
onClick={redirectToCustomerPortal}
|
||||||
disabled={isLoading || !isOwner}
|
disabled={isLoading || !isOwner}
|
||||||
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
|
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
|
||||||
>
|
>
|
||||||
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
|
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
|
||||||
</Button>
|
</Button>
|
||||||
)
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -29,6 +29,10 @@ export default async function BillingPage({
|
||||||
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
return <div>Failed to fetch subscription data. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!subscription) {
|
||||||
|
return <div>todo</div>
|
||||||
|
}
|
||||||
|
|
||||||
const currentUserRole = await getCurrentUserRole(domain)
|
const currentUserRole = await getCurrentUserRole(domain)
|
||||||
if (isServiceError(currentUserRole)) {
|
if (isServiceError(currentUserRole)) {
|
||||||
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
return <div>Failed to fetch user role. Please contact us at team@sourcebot.dev if this issue persists.</div>
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ export default function SettingsLayout({
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
params: { domain: string };
|
params: { domain: string };
|
||||||
}>) {
|
}>) {
|
||||||
|
|
||||||
const sidebarNavItems = [
|
const sidebarNavItems = [
|
||||||
{
|
{
|
||||||
title: "General",
|
title: "General",
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { isServiceError } from "@/lib/utils";
|
||||||
import { useToast } from "@/components/hooks/use-toast";
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
const formSchema = z.object({
|
export const inviteMemberFormSchema = z.object({
|
||||||
emails: z.array(z.object({
|
emails: z.array(z.object({
|
||||||
email: z.string().email()
|
email: z.string().email()
|
||||||
}))
|
}))
|
||||||
|
|
@ -38,8 +38,8 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(inviteMemberFormSchema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
emails: [{ email: "" }]
|
emails: [{ email: "" }]
|
||||||
},
|
},
|
||||||
|
|
@ -50,7 +50,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
||||||
form.setValue('emails', [...emails, { email: "" }]);
|
form.setValue('emails', [...emails, { email: "" }]);
|
||||||
}, [form]);
|
}, [form]);
|
||||||
|
|
||||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
const onSubmit = useCallback((data: z.infer<typeof inviteMemberFormSchema>) => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
createInvites(data.emails.map(e => e.email), domain)
|
createInvites(data.emails.map(e => e.email), domain)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { ENTERPRISE_FEATURES } from "@/lib/constants";
|
||||||
|
import { UpgradeCard } from "./upgradeCard";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
|
||||||
|
export const EnterpriseUpgradeCard = () => {
|
||||||
|
return (
|
||||||
|
<Link href="mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry">
|
||||||
|
<UpgradeCard
|
||||||
|
title="Enterprise"
|
||||||
|
description="For large organizations with custom needs."
|
||||||
|
price="Custom"
|
||||||
|
priceDescription="tailored to your needs"
|
||||||
|
features={ENTERPRISE_FEATURES}
|
||||||
|
buttonText="Contact Us"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { UpgradeCard } from "./upgradeCard";
|
||||||
|
import { createStripeCheckoutSession } from "@/actions";
|
||||||
|
import { useToast } from "@/components/hooks/use-toast";
|
||||||
|
import { useDomain } from "@/hooks/useDomain";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { TEAM_FEATURES } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface TeamUpgradeCardProps {
|
||||||
|
buttonText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => {
|
||||||
|
const domain = useDomain();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const router = useRouter();
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
setIsLoading(true);
|
||||||
|
createStripeCheckoutSession(domain)
|
||||||
|
.then((response) => {
|
||||||
|
if (isServiceError(response)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Stripe checkout failed with error: ${response.message}`,
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
router.push(response.url);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
setIsLoading(false);
|
||||||
|
});
|
||||||
|
}, [domain, router, toast]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UpgradeCard
|
||||||
|
isLoading={isLoading}
|
||||||
|
title="Team"
|
||||||
|
description="For professional developers and small teams."
|
||||||
|
price="$10"
|
||||||
|
priceDescription="per user / month"
|
||||||
|
features={TEAM_FEATURES}
|
||||||
|
buttonText={buttonText}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
|
import { Check, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
|
||||||
|
interface UpgradeCardProps {
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
price: string;
|
||||||
|
priceDescription: string;
|
||||||
|
features: string[];
|
||||||
|
buttonText: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
isLoading?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const UpgradeCard = ({ title, description, price, priceDescription, features, buttonText, onClick, isLoading = false }: UpgradeCardProps) => {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="transition-all duration-300 hover:border-primary/50 cursor-pointer flex flex-col h-full"
|
||||||
|
onClick={() => onClick?.()}
|
||||||
|
>
|
||||||
|
<CardHeader className="space-y-1">
|
||||||
|
<CardTitle className="text-2xl font-bold text-primary">{title}</CardTitle>
|
||||||
|
<CardDescription className="text-base">{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex-grow mb-4">
|
||||||
|
<div className="mb-6">
|
||||||
|
<p className="text-4xl font-bold text-primary">{price}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{priceDescription}</p>
|
||||||
|
</div>
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{features.map((feature, index) => (
|
||||||
|
<li key={index} className="flex items-center">
|
||||||
|
<Check className="mr-3 h-5 w-5 text-green-500 flex-shrink-0" />
|
||||||
|
<span>{feature}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter>
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => onClick?.()}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
{buttonText}
|
||||||
|
</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
packages/web/src/app/[domain]/upgrade/page.tsx
Normal file
69
packages/web/src/app/[domain]/upgrade/page.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
|
import { Footer } from "../components/footer";
|
||||||
|
import { OrgSelector } from "../components/orgSelector";
|
||||||
|
import { EnterpriseUpgradeCard } from "./components/enterpriseUpgradeCard";
|
||||||
|
import { TeamUpgradeCard } from "./components/teamUpgradeCard";
|
||||||
|
import { fetchSubscription } from "@/actions";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { isServiceError } from "@/lib/utils";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { ArrowLeftIcon } from "@radix-ui/react-icons";
|
||||||
|
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
|
||||||
|
|
||||||
|
export default async function Upgrade({ params: { domain } }: { params: { domain: string } }) {
|
||||||
|
|
||||||
|
const subscription = await fetchSubscription(domain);
|
||||||
|
if (!subscription) {
|
||||||
|
redirect(`/${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isServiceError(subscription) && subscription.status === "active") {
|
||||||
|
redirect(`/${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTrialing = !isServiceError(subscription) ? subscription.status === "trialing" : false;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center pt-12 min-h-screen bg-backgroundSecondary relative">
|
||||||
|
{isTrialing && (
|
||||||
|
<Link href={`/${domain}`} className="text-sm text-muted-foreground mb-5 absolute top-0 left-0 p-12">
|
||||||
|
<div className="flex flex-row items-center gap-2">
|
||||||
|
<ArrowLeftIcon className="w-4 h-4" /> Return to dashboard
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
|
||||||
|
<div className="flex flex-col items-center">
|
||||||
|
<SourcebotLogo
|
||||||
|
className="h-16 mb-2"
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold mb-3">
|
||||||
|
{isTrialing ?
|
||||||
|
"Upgrade your trial." :
|
||||||
|
"Your subscription has expired."
|
||||||
|
}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-5">
|
||||||
|
{isTrialing ?
|
||||||
|
"Upgrade now to get the most out of Sourcebot." :
|
||||||
|
"Please upgrade to continue using Sourcebot."
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<OrgSelector
|
||||||
|
domain={domain}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-8 md:grid-cols-2 max-w-4xl mt-12">
|
||||||
|
<TeamUpgradeCard
|
||||||
|
buttonText={isTrialing ? "Upgrade Membership" : "Renew Membership"}
|
||||||
|
/>
|
||||||
|
<EnterpriseUpgradeCard />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Footer />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -5,6 +5,7 @@ import { prisma } from '@/prisma';
|
||||||
import { STRIPE_WEBHOOK_SECRET } from '@/lib/environment';
|
import { STRIPE_WEBHOOK_SECRET } from '@/lib/environment';
|
||||||
import { getStripe } from '@/lib/stripe';
|
import { getStripe } from '@/lib/stripe';
|
||||||
import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db';
|
import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db';
|
||||||
|
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
const body = await req.text();
|
const body = await req.text();
|
||||||
const signature = headers().get('stripe-signature');
|
const signature = headers().get('stripe-signature');
|
||||||
|
|
|
||||||
31
packages/web/src/app/components/logoutEscapeHatch.tsx
Normal file
31
packages/web/src/app/components/logoutEscapeHatch.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { LogOutIcon } from "lucide-react";
|
||||||
|
import { signOut } from "@/auth";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
interface LogoutEscapeHatchProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LogoutEscapeHatch = ({
|
||||||
|
className,
|
||||||
|
}: LogoutEscapeHatchProps) => {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<form
|
||||||
|
action={async () => {
|
||||||
|
"use server";
|
||||||
|
await signOut({
|
||||||
|
redirectTo: "/login",
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="flex flex-row items-center gap-2 text-sm text-muted-foreground cursor-pointer"
|
||||||
|
>
|
||||||
|
<LogOutIcon className="w-4 h-4" />
|
||||||
|
Log out
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
packages/web/src/app/components/redirect.tsx
Normal file
18
packages/web/src/app/components/redirect.tsx
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export const Redirect = ({
|
||||||
|
to,
|
||||||
|
}: {
|
||||||
|
to: string;
|
||||||
|
}) => {
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
router.push(to);
|
||||||
|
}, [router, to]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
17
packages/web/src/app/components/textSeparator.tsx
Normal file
17
packages/web/src/app/components/textSeparator.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
import { cn } from "@/lib/utils"
|
||||||
|
|
||||||
|
|
||||||
|
interface TextSeparatorProps {
|
||||||
|
className?: string;
|
||||||
|
text?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TextSeparator = ({ className, text = "or" }: TextSeparatorProps) => {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex items-center w-full gap-4", className)}>
|
||||||
|
<div className="h-[1px] flex-1 bg-border" />
|
||||||
|
<span className="text-muted-foreground text-sm">{text}</span>
|
||||||
|
<div className="h-[1px] flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -10,6 +10,7 @@ import { cn, getCodeHostIcon } from "@/lib/utils";
|
||||||
import { MagicLinkForm } from "./magicLinkForm";
|
import { MagicLinkForm } from "./magicLinkForm";
|
||||||
import { CredentialsForm } from "./credentialsForm";
|
import { CredentialsForm } from "./credentialsForm";
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
|
import { TextSeparator } from "@/app/components/textSeparator";
|
||||||
|
|
||||||
interface LoginFormProps {
|
interface LoginFormProps {
|
||||||
callbackUrl?: string;
|
callbackUrl?: string;
|
||||||
|
|
@ -122,18 +123,8 @@ const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => {
|
||||||
return (
|
return (
|
||||||
<Fragment key={index}>
|
<Fragment key={index}>
|
||||||
{child}
|
{child}
|
||||||
{index < elements.length - 1 && <Divider key={`divider-${index}`} />}
|
{index < elements.length - 1 && <TextSeparator key={`divider-${index}`} />}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const Divider = ({ className }: { className?: string }) => {
|
|
||||||
return (
|
|
||||||
<div className={cn("flex items-center w-full gap-4", className)}>
|
|
||||||
<div className="h-[1px] flex-1 bg-border" />
|
|
||||||
<span className="text-muted-foreground text-sm">or</span>
|
|
||||||
<div className="h-[1px] flex-1 bg-border" />
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -27,7 +27,7 @@ export default async function Login({ searchParams }: LoginProps) {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center items-center h-screen bg-backgroundSecondary">
|
<div className="flex flex-col items-center p-12 h-screen bg-backgroundSecondary">
|
||||||
<LoginForm
|
<LoginForm
|
||||||
callbackUrl={searchParams.callbackUrl}
|
callbackUrl={searchParams.callbackUrl}
|
||||||
error={searchParams.error}
|
error={searchParams.error}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||||
export default function VerifyPage() {
|
export default function VerifyPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col items-center justify-center h-screen">
|
<div className="flex flex-col items-center p-12 h-screen">
|
||||||
<SourcebotLogo
|
<SourcebotLogo
|
||||||
className="mb-2 h-16"
|
className="mb-2 h-16"
|
||||||
size="small"
|
size="small"
|
||||||
|
|
|
||||||
|
|
@ -1,51 +0,0 @@
|
||||||
import { ErrorPage } from "../components/errorPage";
|
|
||||||
import { auth } from "@/auth";
|
|
||||||
import { getUser } from "@/data/user";
|
|
||||||
import { createOrg, fetchStripeSession } from "../../../actions";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { redirect } from 'next/navigation';
|
|
||||||
|
|
||||||
interface OnboardCompleteProps {
|
|
||||||
searchParams?: {
|
|
||||||
session_id?: string;
|
|
||||||
org_name?: string;
|
|
||||||
org_domain?: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default async function OnboardComplete({ searchParams }: OnboardCompleteProps) {
|
|
||||||
const sessionId = searchParams?.session_id;
|
|
||||||
const orgName = searchParams?.org_name;
|
|
||||||
const orgDomain = searchParams?.org_domain;
|
|
||||||
|
|
||||||
const session = await auth();
|
|
||||||
let user = undefined;
|
|
||||||
if (!session) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
user = await getUser(session.user.id);
|
|
||||||
if (!user) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessionId || !orgName || !orgDomain) {
|
|
||||||
console.error("Missing required parameters");
|
|
||||||
return <ErrorPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeSession = await fetchStripeSession(sessionId);
|
|
||||||
if(stripeSession.payment_status !== "paid") {
|
|
||||||
console.error("Invalid stripe session");
|
|
||||||
return <ErrorPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stripeCustomerId = stripeSession.customer as string;
|
|
||||||
const res = await createOrg(orgName, orgDomain, stripeCustomerId);
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
console.error("Failed to create org");
|
|
||||||
return <ErrorPage />;
|
|
||||||
}
|
|
||||||
|
|
||||||
redirect("/");
|
|
||||||
}
|
|
||||||
|
|
@ -1,37 +0,0 @@
|
||||||
"use client"
|
|
||||||
|
|
||||||
import { useRouter } from "next/navigation"
|
|
||||||
import { XCircle } from "lucide-react"
|
|
||||||
import { Button } from "@/components/ui/button"
|
|
||||||
import { Card, CardContent } from "@/components/ui/card"
|
|
||||||
|
|
||||||
export function ErrorPage() {
|
|
||||||
const router = useRouter()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen w-full flex items-center justify-center p-4">
|
|
||||||
<Card className="w-full max-w-md">
|
|
||||||
<CardContent className="pt-12 pb-8 px-8 flex flex-col items-center text-center">
|
|
||||||
<div className="mb-6">
|
|
||||||
<XCircle className="h-16 w-16 text-red-500" />
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold mb-8">Organization Creation Failed</h1>
|
|
||||||
<p className="text-gray-400 mb-4">
|
|
||||||
We encountered an error while creating your organization. Please try again.
|
|
||||||
</p>
|
|
||||||
<p className="text-gray-400 mb-8">
|
|
||||||
If the problem persists, please contact us at team@sourcebot.dev
|
|
||||||
</p>
|
|
||||||
<Button
|
|
||||||
onClick={() => router.push("/onboard")}
|
|
||||||
className="px-6 py-2 h-auto text-base font-medium rounded-xl"
|
|
||||||
variant="secondary"
|
|
||||||
>
|
|
||||||
Try Again
|
|
||||||
</Button>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
35
packages/web/src/app/onboard/components/onboardHeader.tsx
Normal file
35
packages/web/src/app/onboard/components/onboardHeader.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
|
|
||||||
|
interface OnboardHeaderProps {
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
step: OnboardingSteps
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OnboardHeader = ({ title, description, step: currentStep }: OnboardHeaderProps) => {
|
||||||
|
const steps = Object.values(OnboardingSteps).filter(s => s !== OnboardingSteps.Complete);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center text-center mb-10">
|
||||||
|
<SourcebotLogo
|
||||||
|
className="h-16 mb-2"
|
||||||
|
size="large"
|
||||||
|
/>
|
||||||
|
<h1 className="text-3xl font-bold mb-3">
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mb-5">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
{steps.map((step, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`h-1.5 w-6 rounded-full transition-colors ${step === currentStep ? "bg-gray-400" : "bg-gray-200"}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { checkIfOrgDomainExists } from "../../../actions"
|
import { checkIfOrgDomainExists, createOrg } from "../../../actions"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
|
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
|
||||||
import { isServiceError } from "@/lib/utils"
|
|
||||||
import { useForm } from "react-hook-form"
|
import { useForm } from "react-hook-form"
|
||||||
import { z } from "zod"
|
import { z } from "zod"
|
||||||
import { zodResolver } from "@hookform/resolvers/zod"
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
import { useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
import { SourcebotLogo } from "@/app/components/sourcebotLogo"
|
||||||
|
import { isServiceError } from "@/lib/utils"
|
||||||
|
import { Loader2 } from "lucide-react"
|
||||||
|
import { useToast } from "@/components/hooks/use-toast"
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { Card } from "@/components/ui/card"
|
||||||
|
|
||||||
const onboardingFormSchema = z.object({
|
const onboardingFormSchema = z.object({
|
||||||
name: z.string()
|
name: z.string()
|
||||||
|
|
@ -20,55 +24,46 @@ const onboardingFormSchema = z.object({
|
||||||
.max(20, { message: "Organization domain must be at most 20 characters long." })
|
.max(20, { message: "Organization domain must be at most 20 characters long." })
|
||||||
.regex(/^[a-z][a-z-]*[a-z]$/, {
|
.regex(/^[a-z][a-z-]*[a-z]$/, {
|
||||||
message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.",
|
message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.",
|
||||||
}),
|
})
|
||||||
|
.refine(async (domain) => {
|
||||||
|
const doesDomainExist = await checkIfOrgDomainExists(domain);
|
||||||
|
return isServiceError(doesDomainExist) || !doesDomainExist;
|
||||||
|
}, "This domain is already taken."),
|
||||||
})
|
})
|
||||||
|
|
||||||
export type OnboardingFormValues = z.infer<typeof onboardingFormSchema>
|
export function OrgCreateForm() {
|
||||||
|
const { toast } = useToast();
|
||||||
const defaultValues: Partial<OnboardingFormValues> = {
|
const router = useRouter();
|
||||||
name: "",
|
const form = useForm<z.infer<typeof onboardingFormSchema>>({
|
||||||
domain: "",
|
resolver: zodResolver(onboardingFormSchema),
|
||||||
}
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
interface OrgCreateFormProps {
|
domain: "",
|
||||||
setOrgCreateData: (data: OnboardingFormValues) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
|
|
||||||
const form = useForm<OnboardingFormValues>({ resolver: zodResolver(onboardingFormSchema), defaultValues })
|
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function submitOrgInfoForm(data: OnboardingFormValues) {
|
|
||||||
const res = await checkIfOrgDomainExists(data.domain);
|
|
||||||
if (isServiceError(res)) {
|
|
||||||
setErrorMessage("An error occurred while checking the domain. Please try clearing your cookies and trying again.");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
const { isSubmitting } = form.formState;
|
||||||
|
|
||||||
if (res) {
|
const onSubmit = useCallback(async (data: z.infer<typeof onboardingFormSchema>) => {
|
||||||
setErrorMessage("Organization domain already exists. Please try a different one.");
|
const response = await createOrg(data.name, data.domain);
|
||||||
return;
|
if (isServiceError(response)) {
|
||||||
|
toast({
|
||||||
|
description: `❌ Failed to create organization. Reason: ${response.message}`
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
setOrgCreateData(data);
|
router.push(`/${data.domain}/onboard`);
|
||||||
}
|
}
|
||||||
}
|
}, [router, toast]);
|
||||||
|
|
||||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const name = e.target.value
|
const name = e.target.value
|
||||||
const domain = name.toLowerCase().replace(/\s+/g, "-")
|
const domain = name.toLowerCase().replace(/\s+/g, "-")
|
||||||
form.setValue("domain", domain)
|
form.setValue("domain", domain)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<Card className="flex flex-col border p-12 space-y-6 bg-background w-96">
|
||||||
<div className="flex justify-center">
|
|
||||||
<SourcebotLogo
|
|
||||||
className="h-16"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl font-bold">Let's create your organization</h1>
|
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
<form onSubmit={form.handleSubmit(submitOrgInfoForm)} className="space-y-8">
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="name"
|
name="name"
|
||||||
|
|
@ -76,9 +71,10 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Organization Name</FormLabel>
|
<FormLabel>Organization Name</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Input
|
<Input
|
||||||
placeholder="Aperture Labs"
|
placeholder="Aperture Labs"
|
||||||
{...field}
|
{...field}
|
||||||
|
autoFocus
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
field.onChange(e)
|
field.onChange(e)
|
||||||
handleNameChange(e)
|
handleNameChange(e)
|
||||||
|
|
@ -105,12 +101,17 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{errorMessage && <p className="text-red-500">{errorMessage}</p>}
|
<Button
|
||||||
<div className="flex justify-center">
|
variant="default"
|
||||||
<Button type="submit">Create</Button>
|
className="w-full"
|
||||||
</div>
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</div>
|
</Card>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,85 +0,0 @@
|
||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
Card,
|
|
||||||
CardHeader,
|
|
||||||
CardTitle,
|
|
||||||
CardDescription,
|
|
||||||
CardContent,
|
|
||||||
} from "@/components/ui/card";
|
|
||||||
import { Check } from "lucide-react";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
|
|
||||||
import { setupInitialStripeCustomer } from "../../../actions"
|
|
||||||
import {
|
|
||||||
EmbeddedCheckout,
|
|
||||||
EmbeddedCheckoutProvider
|
|
||||||
} from '@stripe/react-stripe-js'
|
|
||||||
import { loadStripe } from '@stripe/stripe-js'
|
|
||||||
import { useState } from "react";
|
|
||||||
import { OnboardingFormValues } from "./orgCreateForm";
|
|
||||||
import { isServiceError } from "@/lib/utils";
|
|
||||||
import { NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY } from "@/lib/environment.client";
|
|
||||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
|
||||||
|
|
||||||
const stripePromise = loadStripe(NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!)
|
|
||||||
|
|
||||||
export function TrialCard({ orgCreateInfo }: { orgCreateInfo: OnboardingFormValues }) {
|
|
||||||
const [trialAck, setTrialAck] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{trialAck ? (
|
|
||||||
<div id="checkout">
|
|
||||||
<EmbeddedCheckoutProvider
|
|
||||||
stripe={stripePromise}
|
|
||||||
options={{ fetchClientSecret: async () => {
|
|
||||||
const clientSecret = await setupInitialStripeCustomer(orgCreateInfo.name, orgCreateInfo.domain);
|
|
||||||
if (isServiceError(clientSecret)) {
|
|
||||||
throw clientSecret;
|
|
||||||
}
|
|
||||||
return clientSecret;
|
|
||||||
} }}
|
|
||||||
>
|
|
||||||
<EmbeddedCheckout />
|
|
||||||
</EmbeddedCheckoutProvider>
|
|
||||||
</div>
|
|
||||||
) :
|
|
||||||
<Card className="w-full max-w-md mx-auto">
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex justify-center mb-4">
|
|
||||||
<SourcebotLogo
|
|
||||||
className="h-16"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<CardTitle className="text-center text-2xl font-bold">7 day free trial</CardTitle>
|
|
||||||
<CardDescription className="text-center mt-2">Cancel anytime. No credit card required.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-2">
|
|
||||||
<ul className="space-y-4 mb-6">
|
|
||||||
{[
|
|
||||||
"Blazingly fast code search",
|
|
||||||
"Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code sources supported.",
|
|
||||||
"Public and private repos supported.",
|
|
||||||
"Create sharable links to code snippets.",
|
|
||||||
"Powerful regex and symbol search",
|
|
||||||
].map((feature, index) => (
|
|
||||||
<li key={index} className="flex items-center">
|
|
||||||
<div className="mr-3 flex-shrink-0">
|
|
||||||
<Check className="h-5 w-5 text-sky-500" />
|
|
||||||
</div>
|
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-300">{feature}</p>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<div className="flex justify-center mt-8">
|
|
||||||
<Button onClick={() => setTrialAck(true)} className="px-8 py-2">
|
|
||||||
Start trial
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,35 +1,25 @@
|
||||||
"use client";
|
import { OrgCreateForm } from "./components/orgCreateForm";
|
||||||
|
import { auth } from "@/auth";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { OnboardHeader } from "./components/onboardHeader";
|
||||||
|
import { OnboardingSteps } from "@/lib/constants";
|
||||||
|
import { LogoutEscapeHatch } from "../components/logoutEscapeHatch";
|
||||||
|
|
||||||
import { useState, useEffect} from "react";
|
export default async function Onboarding() {
|
||||||
import { OrgCreateForm, OnboardingFormValues } from "./components/orgCreateForm";
|
const session = await auth();
|
||||||
import { TrialCard } from "./components/trialInfoCard";
|
if (!session) {
|
||||||
import { isAuthed } from "@/actions";
|
redirect("/login");
|
||||||
import { useRouter } from "next/navigation";
|
}
|
||||||
|
|
||||||
export default function Onboarding() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [orgCreateInfo, setOrgInfo] = useState<OnboardingFormValues | undefined>(undefined);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const redirectIfNotAuthed = async () => {
|
|
||||||
const authed = await isAuthed();
|
|
||||||
if(!authed) {
|
|
||||||
router.push("/login");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
redirectIfNotAuthed();
|
|
||||||
}, [router]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center items-center h-screen">
|
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary fade-in-20 relative">
|
||||||
{orgCreateInfo ? (
|
<OnboardHeader
|
||||||
<TrialCard orgCreateInfo={ orgCreateInfo } />
|
title="Setup your organization"
|
||||||
) : (
|
description="Create a organization for your team to search and share code across your repositories."
|
||||||
<div className="flex flex-col items-center border p-16 rounded-lg gap-6">
|
step={OnboardingSteps.CreateOrg}
|
||||||
<OrgCreateForm setOrgCreateData={setOrgInfo} />
|
/>
|
||||||
</div>
|
<OrgCreateForm />
|
||||||
)}
|
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,16 +74,6 @@ export default async function RedeemPage({ searchParams }: RedeemPageProps) {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stripeCustomerId = org.stripeCustomerId;
|
|
||||||
if (stripeCustomerId) {
|
|
||||||
const subscription = await fetchSubscription(org.domain);
|
|
||||||
if (isServiceError(subscription)) {
|
|
||||||
return (
|
|
||||||
<ErrorLayout title="This organization's subscription has expired. Please renew the subscription and try again." />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
<div className="flex flex-col justify-center items-center mt-8 mb-8 md:mt-18 w-full px-5">
|
||||||
<div className="max-h-44 w-auto mb-4">
|
<div className="max-h-44 w-auto mb-4">
|
||||||
|
|
|
||||||
24
packages/web/src/lib/constants.ts
Normal file
24
packages/web/src/lib/constants.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
|
||||||
|
// @note: Order is important here.
|
||||||
|
export enum OnboardingSteps {
|
||||||
|
CreateOrg = 'create-org',
|
||||||
|
ConnectCodeHost = 'connect-code-host',
|
||||||
|
InviteTeam = 'invite-team',
|
||||||
|
Checkout = 'checkout',
|
||||||
|
Complete = 'complete',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ENTERPRISE_FEATURES = [
|
||||||
|
"All Team features",
|
||||||
|
"Dedicated Slack support channel",
|
||||||
|
"Single tenant deployment",
|
||||||
|
"Advanced security features",
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TEAM_FEATURES = [
|
||||||
|
"Blazingly fast code search",
|
||||||
|
"Index hundreds of repos from multiple code hosts (GitHub, GitLab, Gerrit, Gitea, etc.). Self-hosted code hosts supported.",
|
||||||
|
"Public and private repos supported.",
|
||||||
|
"Create sharable links to code snippets.",
|
||||||
|
"Powerful regex and symbol search",
|
||||||
|
]
|
||||||
|
|
@ -16,4 +16,5 @@ export enum ErrorCode {
|
||||||
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
|
CONNECTION_NOT_FAILED = 'CONNECTION_NOT_FAILED',
|
||||||
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
|
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
|
||||||
INVALID_INVITE = 'INVALID_INVITE',
|
INVALID_INVITE = 'INVALID_INVITE',
|
||||||
|
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue