mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 12:25:22 +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[]
|
||||
repos Repo[]
|
||||
secrets Secret[]
|
||||
isOnboarded Boolean @default(false)
|
||||
|
||||
stripeCustomerId String?
|
||||
stripeSubscriptionStatus StripeSubscriptionStatus?
|
||||
stripeLastUpdatedAt DateTime?
|
||||
stripeCustomerId String?
|
||||
stripeSubscriptionStatus StripeSubscriptionStatus?
|
||||
stripeLastUpdatedAt DateTime?
|
||||
|
||||
/// List of pending invites to this organization
|
||||
invites Invite[]
|
||||
|
|
@ -165,14 +166,14 @@ model Secret {
|
|||
|
||||
// @see : https://authjs.dev/concepts/database-models#user
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
hashedPassword String?
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
accounts Account[]
|
||||
orgs UserToOrg[]
|
||||
id String @id @default(cuid())
|
||||
name String?
|
||||
email String? @unique
|
||||
hashedPassword String?
|
||||
emailVerified DateTime?
|
||||
image String?
|
||||
accounts Account[]
|
||||
orgs UserToOrg[]
|
||||
|
||||
/// List of pending invites that the user has created
|
||||
invites Invite[]
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"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": {
|
||||
"@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 { encrypt } from "@sourcebot/crypto"
|
||||
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 { getStripe } from "@/lib/stripe"
|
||||
import { getUser } from "@/data/user";
|
||||
import { Session } from "next-auth";
|
||||
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN } from "@/lib/environment";
|
||||
import { StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import Stripe from "stripe";
|
||||
import { SyncStatusMetadataSchema, type NotFoundData } from "@/lib/syncStatusMetadataSchema";
|
||||
import { OnboardingSteps } from "./lib/constants";
|
||||
|
||||
const ajv = new Ajv({
|
||||
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.",
|
||||
} satisfies ServiceError;
|
||||
}
|
||||
|
||||
|
||||
return fn({
|
||||
orgId: org.id,
|
||||
userRole: membership.role,
|
||||
|
|
@ -88,15 +88,12 @@ export const isAuthed = async () => {
|
|||
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) => {
|
||||
const org = await prisma.org.create({
|
||||
data: {
|
||||
name,
|
||||
domain,
|
||||
stripeCustomerId,
|
||||
stripeSubscriptionStatus: StripeSubscriptionStatus.ACTIVE,
|
||||
stripeLastUpdatedAt: new Date(),
|
||||
members: {
|
||||
create: {
|
||||
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> =>
|
||||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -436,7 +480,7 @@ export const getCurrentUserRole = async (domain: string): Promise<OrgRole | Serv
|
|||
withAuth((session) =>
|
||||
withOrgMembership(session, domain, async ({ userRole }) => {
|
||||
return userRole;
|
||||
})
|
||||
})
|
||||
);
|
||||
|
||||
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 {
|
||||
success: true,
|
||||
|
|
@ -539,12 +583,12 @@ export const redeemInvite = async (invite: Invite, userId: string): Promise<{ su
|
|||
if (!org) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// Incrememnt the seat count
|
||||
if (org.stripeCustomerId) {
|
||||
const subscription = await fetchSubscription(org.domain);
|
||||
|
||||
// @note: we need to use the internal subscription fetch here since we would otherwise fail the `withOrgMembership` check.
|
||||
const subscription = await _fetchSubscriptionForOrg(org.id, tx);
|
||||
if (subscription) {
|
||||
if (isServiceError(subscription)) {
|
||||
throw orgInvalidSubscription();
|
||||
return subscription;
|
||||
}
|
||||
|
||||
const existingSeatCount = subscription.items.data[0].quantity;
|
||||
|
|
@ -740,57 +784,100 @@ const parseConnectionConfig = (connectionType: string, config: string) => {
|
|||
return parsedConfig;
|
||||
}
|
||||
|
||||
export const setupInitialStripeCustomer = async (name: string, domain: string) =>
|
||||
withAuth(async (session) => {
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return "";
|
||||
}
|
||||
export const createOnboardingStripeCheckoutSession = async (domain: string) =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
const stripe = getStripe();
|
||||
const origin = (await headers()).get('origin')
|
||||
if (!org) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
// @nocheckin
|
||||
const test_clock = await stripe.testHelpers.testClocks.create({
|
||||
frozen_time: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
const user = await getUser(session.user.id);
|
||||
if (!user) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
name: user.name!,
|
||||
email: user.email!,
|
||||
test_clock: test_clock.id
|
||||
})
|
||||
const stripe = getStripe();
|
||||
const origin = (await headers()).get('origin');
|
||||
|
||||
const prices = await stripe.prices.list({
|
||||
product: STRIPE_PRODUCT_ID,
|
||||
expand: ['data.product'],
|
||||
});
|
||||
const stripeSession = await stripe.checkout.sessions.create({
|
||||
ui_mode: 'embedded',
|
||||
customer: customer.id,
|
||||
line_items: [
|
||||
{
|
||||
price: prices.data[0].id,
|
||||
quantity: 1
|
||||
// @nocheckin
|
||||
const test_clock = await stripe.testHelpers.testClocks.create({
|
||||
frozen_time: Math.floor(Date.now() / 1000)
|
||||
});
|
||||
|
||||
// Use the existing customer if it exists, otherwise create a new one.
|
||||
const customerId = await (async () => {
|
||||
if (org.stripeCustomerId) {
|
||||
return org.stripeCustomerId;
|
||||
}
|
||||
],
|
||||
mode: 'subscription',
|
||||
subscription_data: {
|
||||
trial_period_days: 7,
|
||||
trial_settings: {
|
||||
end_behavior: {
|
||||
missing_payment_method: 'cancel',
|
||||
|
||||
const customer = await stripe.customers.create({
|
||||
name: org.name,
|
||||
email: user.email ?? undefined,
|
||||
test_clock: test_clock.id,
|
||||
description: `Created by ${user.email} on ${domain} (id: ${org.id})`,
|
||||
});
|
||||
|
||||
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',
|
||||
return_url: `${origin}/onboard/complete?session_id={CHECKOUT_SESSION_ID}&org_name=${name}&org_domain=${domain}`,
|
||||
})
|
||||
payment_method_collection: 'if_required',
|
||||
success_url: `${origin}/${domain}/onboard?step=${OnboardingSteps.Complete}&stripe_session_id={CHECKOUT_SESSION_ID}`,
|
||||
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) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
const org = await prisma.org.findUnique({
|
||||
|
|
@ -820,35 +907,36 @@ export const getSubscriptionCheckoutRedirect = async (domain: string) =>
|
|||
expand: ['data.product'],
|
||||
});
|
||||
|
||||
const createNewSubscription = async () => {
|
||||
const stripeSession = await stripe.checkout.sessions.create({
|
||||
customer: org.stripeCustomerId as string,
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: prices.data[0].id,
|
||||
quantity: numOrgMembers
|
||||
}
|
||||
],
|
||||
mode: 'subscription',
|
||||
payment_method_collection: 'always',
|
||||
success_url: `${origin}/${domain}/settings/billing`,
|
||||
cancel_url: `${origin}/${domain}`,
|
||||
});
|
||||
const stripeSession = await stripe.checkout.sessions.create({
|
||||
customer: org.stripeCustomerId as string,
|
||||
payment_method_types: ['card'],
|
||||
line_items: [
|
||||
{
|
||||
price: prices.data[0].id,
|
||||
quantity: numOrgMembers
|
||||
}
|
||||
],
|
||||
mode: 'subscription',
|
||||
payment_method_collection: 'always',
|
||||
success_url: `${origin}/${domain}/settings/billing`,
|
||||
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 newSubscriptionUrl;
|
||||
return {
|
||||
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> =>
|
||||
withAuth((session) =>
|
||||
|
|
@ -874,29 +962,39 @@ export const getCustomerPortalSessionLink = async (domain: string): Promise<stri
|
|||
}, /* minRequiredRole = */ OrgRole.OWNER)
|
||||
);
|
||||
|
||||
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | ServiceError> =>
|
||||
withAuth(async () => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
domain,
|
||||
},
|
||||
});
|
||||
export const fetchSubscription = (domain: string): Promise<Stripe.Subscription | null | ServiceError> =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
return _fetchSubscriptionForOrg(orgId, prisma);
|
||||
})
|
||||
);
|
||||
|
||||
if (!org || !org.stripeCustomerId) {
|
||||
return notFound();
|
||||
}
|
||||
|
||||
const stripe = getStripe();
|
||||
const subscriptions = await stripe.subscriptions.list({
|
||||
customer: org.stripeCustomerId
|
||||
});
|
||||
|
||||
if (subscriptions.data.length === 0) {
|
||||
return notFound();
|
||||
}
|
||||
return subscriptions.data[0];
|
||||
const _fetchSubscriptionForOrg = async (orgId: number, prisma: Prisma.TransactionClient): Promise<Stripe.Subscription | null | ServiceError> => {
|
||||
const org = await prisma.org.findUnique({
|
||||
where: {
|
||||
id: orgId,
|
||||
},
|
||||
});
|
||||
|
||||
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> =>
|
||||
withAuth(async (session) =>
|
||||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
|
|
@ -990,10 +1088,10 @@ export const removeMemberFromOrg = async (memberId: string, domain: string): Pro
|
|||
return notFound();
|
||||
}
|
||||
|
||||
if (org.stripeCustomerId) {
|
||||
const subscription = await fetchSubscription(domain);
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (subscription) {
|
||||
if (isServiceError(subscription)) {
|
||||
return orgInvalidSubscription();
|
||||
return subscription;
|
||||
}
|
||||
|
||||
const existingSeatCount = subscription.items.data[0].quantity;
|
||||
|
|
@ -1045,10 +1143,10 @@ export const leaveOrg = async (domain: string): Promise<{ success: boolean } | S
|
|||
return notFound();
|
||||
}
|
||||
|
||||
if (org.stripeCustomerId) {
|
||||
const subscription = await fetchSubscription(domain);
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (subscription) {
|
||||
if (isServiceError(subscription)) {
|
||||
return orgInvalidSubscription();
|
||||
return subscription;
|
||||
}
|
||||
|
||||
const existingSeatCount = subscription.items.data[0].quantity;
|
||||
|
|
@ -1084,7 +1182,11 @@ export const getSubscriptionData = async (domain: string) =>
|
|||
withOrgMembership(session, domain, async () => {
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (isServiceError(subscription)) {
|
||||
return orgInvalidSubscription();
|
||||
return subscription;
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { Schema } from "ajv";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ConfigEditor, QuickActionFn } from "../../../components/configEditor";
|
||||
import { ConfigEditor, QuickActionFn } from "../configEditor";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
interface ConnectionCreationForm<T> {
|
||||
interface SharedConnectionCreationFormProps<T> {
|
||||
type: 'github' | 'gitlab' | 'gitea' | 'gerrit';
|
||||
defaultValues: {
|
||||
name: string;
|
||||
|
|
@ -30,18 +31,21 @@ interface ConnectionCreationForm<T> {
|
|||
name: string;
|
||||
fn: QuickActionFn<T>;
|
||||
}[],
|
||||
className?: string;
|
||||
onCreated?: (id: number) => void;
|
||||
}
|
||||
|
||||
export default function ConnectionCreationForm<T>({
|
||||
export default function SharedConnectionCreationForm<T>({
|
||||
type,
|
||||
defaultValues,
|
||||
title,
|
||||
schema,
|
||||
quickActions,
|
||||
}: ConnectionCreationForm<T>) {
|
||||
className,
|
||||
onCreated,
|
||||
}: SharedConnectionCreationFormProps<T>) {
|
||||
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const domain = useDomain();
|
||||
|
||||
const formSchema = useMemo(() => {
|
||||
|
|
@ -55,26 +59,24 @@ export default function ConnectionCreationForm<T>({
|
|||
resolver: zodResolver(formSchema),
|
||||
defaultValues: defaultValues,
|
||||
});
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
||||
createConnection(data.name, type, data.config, domain)
|
||||
.then((response) => {
|
||||
if (isServiceError(response)) {
|
||||
toast({
|
||||
description: `❌ Failed to create connection. Reason: ${response.message}`
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Connection created successfully.`
|
||||
});
|
||||
router.push(`/${domain}/connections`);
|
||||
router.refresh();
|
||||
}
|
||||
const onSubmit = useCallback(async (data: z.infer<typeof formSchema>) => {
|
||||
const response = await createConnection(data.name, type, data.config, domain);
|
||||
if (isServiceError(response)) {
|
||||
toast({
|
||||
description: `❌ Failed to create connection. Reason: ${response.message}`
|
||||
});
|
||||
}, [domain, router, toast, type]);
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Connection created successfully.`
|
||||
});
|
||||
onCreated?.(response.id);
|
||||
}
|
||||
}, [domain, toast, type, onCreated]);
|
||||
|
||||
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">
|
||||
<ConnectionIcon
|
||||
type={type}
|
||||
|
|
@ -128,7 +130,14 @@ export default function ConnectionCreationForm<T>({
|
|||
}}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
|
|
@ -89,7 +89,7 @@ export const NavigationMenu = async ({
|
|||
<ProgressNavIndicator />
|
||||
<WarningNavIndicator />
|
||||
<ErrorNavIndicator />
|
||||
{!isServiceError(subscription) && subscription.status === "trialing" && (
|
||||
{!isServiceError(subscription) && subscription && subscription.status === "trialing" && (
|
||||
<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">
|
||||
<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';
|
||||
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { CaretSortIcon, CheckIcon, PlusCircledIcon } from "@radix-ui/react-icons";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { OrgIcon } from "./orgIcon";
|
||||
|
|
@ -108,6 +109,20 @@ export const OrgSelectorDropdown = ({
|
|||
</CommandList>
|
||||
</Command>
|
||||
</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>
|
||||
</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 { useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
import { ConfigEditor, QuickAction } from "../../components/configEditor";
|
||||
import { ConfigEditor, QuickAction } from "../../../components/configEditor";
|
||||
import { createZodConnectionConfigValidator } from "../../utils";
|
||||
import { GithubConnectionConfig } from "@sourcebot/schemas/v3/github.type";
|
||||
import { GiteaConnectionConfig } from "@sourcebot/schemas/v3/gitea.type";
|
||||
|
|
|
|||
|
|
@ -1,115 +1,38 @@
|
|||
'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 {
|
||||
GitHubConnectionCreationForm,
|
||||
GitLabConnectionCreationForm,
|
||||
GiteaConnectionCreationForm,
|
||||
GerritConnectionCreationForm
|
||||
} from "@/app/[domain]/components/connectionCreationForms";
|
||||
import { useCallback } from "react";
|
||||
export default function NewConnectionPage({
|
||||
params
|
||||
}: { params: { type: string } }) {
|
||||
const { type } = params;
|
||||
const router = useRouter();
|
||||
|
||||
const onCreated = useCallback(() => {
|
||||
router.push('/connections');
|
||||
}, [router]);
|
||||
|
||||
if (type === 'github') {
|
||||
return <GitHubCreationForm />;
|
||||
return <GitHubConnectionCreationForm onCreated={onCreated} />;
|
||||
}
|
||||
|
||||
if (type === 'gitlab') {
|
||||
return <GitLabCreationForm />;
|
||||
return <GitLabConnectionCreationForm onCreated={onCreated} />;
|
||||
}
|
||||
|
||||
if (type === 'gitea') {
|
||||
return <GiteaCreationForm />;
|
||||
return <GiteaConnectionCreationForm onCreated={onCreated} />;
|
||||
}
|
||||
|
||||
if (type === 'gerrit') {
|
||||
return <GerritCreationForm />;
|
||||
return <GerritConnectionCreationForm onCreated={onCreated} />;
|
||||
}
|
||||
|
||||
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 { 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 { GerritConnectionConfig } from "@sourcebot/schemas/v3/gerrit.type";
|
||||
|
||||
|
|
|
|||
|
|
@ -2,11 +2,10 @@ import { prisma } from "@/prisma";
|
|||
import { PageNotFound } from "./components/pageNotFound";
|
||||
import { auth } from "@/auth";
|
||||
import { getOrgFromDomain } from "@/data/org";
|
||||
import { fetchSubscription } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { PaywallCard } from "./components/payWall/paywallCard";
|
||||
import { NavigationMenu } from "./components/navigationMenu";
|
||||
import { Footer } from "./components/footer";
|
||||
import { OnboardGuard } from "./components/onboardGuard";
|
||||
import { fetchSubscription } from "@/actions";
|
||||
import { UpgradeGuard } from "./components/upgradeGuard";
|
||||
|
||||
interface LayoutProps {
|
||||
children: React.ReactNode,
|
||||
|
|
@ -43,14 +42,26 @@ export default async function Layout({
|
|||
return <PageNotFound />
|
||||
}
|
||||
|
||||
const subscription = await fetchSubscription(domain);
|
||||
if (isServiceError(subscription) || (subscription.status !== "active" && subscription.status !== "trialing")) {
|
||||
if (!org.isOnboarded) {
|
||||
return (
|
||||
<div className="flex flex-col items-center overflow-hidden min-h-screen">
|
||||
<NavigationMenu domain={domain} />
|
||||
<PaywallCard domain={domain} />
|
||||
<Footer />
|
||||
</div>
|
||||
<OnboardGuard>
|
||||
{children}
|
||||
</OnboardGuard>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
<FormLabel>Email address</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={billingEmail}
|
||||
<Input
|
||||
placeholder={billingEmail}
|
||||
{...field}
|
||||
disabled={currentUserRole !== OrgRole.OWNER}
|
||||
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
||||
|
|
@ -94,14 +94,15 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
|
||||
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
||||
>
|
||||
{isLoading ? "Updating..." : "Update Billing Email"}
|
||||
</Button>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isLoading || currentUserRole !== OrgRole.OWNER}
|
||||
title={currentUserRole !== OrgRole.OWNER ? "Only organization owners can change the billing email" : undefined}
|
||||
>
|
||||
{isLoading ? "Updating..." : "Update Billing Email"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
|
|
|
|||
|
|
@ -31,13 +31,14 @@ export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole:
|
|||
|
||||
const isOwner = currentUserRole === OrgRole.OWNER
|
||||
return (
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={redirectToCustomerPortal}
|
||||
disabled={isLoading || !isOwner}
|
||||
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
|
||||
>
|
||||
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
|
||||
</Button>
|
||||
)
|
||||
<div className="flex w-full justify-end">
|
||||
<Button
|
||||
onClick={redirectToCustomerPortal}
|
||||
disabled={isLoading || !isOwner}
|
||||
title={!isOwner ? "Only the owner of the org can manage the subscription" : undefined}
|
||||
>
|
||||
{isLoading ? "Creating customer portal..." : "Manage Subscription"}
|
||||
</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>
|
||||
}
|
||||
|
||||
if (!subscription) {
|
||||
return <div>todo</div>
|
||||
}
|
||||
|
||||
const currentUserRole = await getCurrentUserRole(domain)
|
||||
if (isServiceError(currentUserRole)) {
|
||||
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;
|
||||
params: { domain: string };
|
||||
}>) {
|
||||
|
||||
const sidebarNavItems = [
|
||||
{
|
||||
title: "General",
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ import { isServiceError } from "@/lib/utils";
|
|||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
const formSchema = z.object({
|
||||
export const inviteMemberFormSchema = z.object({
|
||||
emails: z.array(z.object({
|
||||
email: z.string().email()
|
||||
}))
|
||||
|
|
@ -38,8 +38,8 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
|||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
|
||||
resolver: zodResolver(inviteMemberFormSchema),
|
||||
defaultValues: {
|
||||
emails: [{ email: "" }]
|
||||
},
|
||||
|
|
@ -50,7 +50,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
|||
form.setValue('emails', [...emails, { email: "" }]);
|
||||
}, [form]);
|
||||
|
||||
const onSubmit = useCallback((data: z.infer<typeof formSchema>) => {
|
||||
const onSubmit = useCallback((data: z.infer<typeof inviteMemberFormSchema>) => {
|
||||
setIsLoading(true);
|
||||
createInvites(data.emails.map(e => e.email), domain)
|
||||
.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 { getStripe } from '@/lib/stripe';
|
||||
import { ConnectionSyncStatus, StripeSubscriptionStatus } from '@sourcebot/db';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const body = await req.text();
|
||||
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 { CredentialsForm } from "./credentialsForm";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { TextSeparator } from "@/app/components/textSeparator";
|
||||
|
||||
interface LoginFormProps {
|
||||
callbackUrl?: string;
|
||||
|
|
@ -122,18 +123,8 @@ const DividerSet = ({ elements }: { elements: React.ReactNode[] }) => {
|
|||
return (
|
||||
<Fragment key={index}>
|
||||
{child}
|
||||
{index < elements.length - 1 && <Divider key={`divider-${index}`} />}
|
||||
{index < elements.length - 1 && <TextSeparator key={`divider-${index}`} />}
|
||||
</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 (
|
||||
<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
|
||||
callbackUrl={searchParams.callbackUrl}
|
||||
error={searchParams.error}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
|||
export default function VerifyPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-screen">
|
||||
<div className="flex flex-col items-center p-12 h-screen">
|
||||
<SourcebotLogo
|
||||
className="mb-2 h-16"
|
||||
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"
|
||||
|
||||
import { checkIfOrgDomainExists } from "../../../actions"
|
||||
import { checkIfOrgDomainExists, createOrg } from "../../../actions"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/components/ui/form"
|
||||
import { isServiceError } from "@/lib/utils"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
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({
|
||||
name: z.string()
|
||||
|
|
@ -20,55 +24,46 @@ const onboardingFormSchema = z.object({
|
|||
.max(20, { message: "Organization domain must be at most 20 characters long." })
|
||||
.regex(/^[a-z][a-z-]*[a-z]$/, {
|
||||
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>
|
||||
|
||||
const defaultValues: Partial<OnboardingFormValues> = {
|
||||
name: "",
|
||||
domain: "",
|
||||
}
|
||||
|
||||
interface OrgCreateFormProps {
|
||||
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;
|
||||
export function OrgCreateForm() {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const form = useForm<z.infer<typeof onboardingFormSchema>>({
|
||||
resolver: zodResolver(onboardingFormSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
domain: "",
|
||||
}
|
||||
});
|
||||
const { isSubmitting } = form.formState;
|
||||
|
||||
if (res) {
|
||||
setErrorMessage("Organization domain already exists. Please try a different one.");
|
||||
return;
|
||||
const onSubmit = useCallback(async (data: z.infer<typeof onboardingFormSchema>) => {
|
||||
const response = await createOrg(data.name, data.domain);
|
||||
if (isServiceError(response)) {
|
||||
toast({
|
||||
description: `❌ Failed to create organization. Reason: ${response.message}`
|
||||
})
|
||||
} else {
|
||||
setOrgCreateData(data);
|
||||
router.push(`/${data.domain}/onboard`);
|
||||
}
|
||||
}
|
||||
}, [router, toast]);
|
||||
|
||||
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const name = e.target.value
|
||||
const domain = name.toLowerCase().replace(/\s+/g, "-")
|
||||
form.setValue("domain", domain)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-center">
|
||||
<SourcebotLogo
|
||||
className="h-16"
|
||||
/>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold">Let's create your organization</h1>
|
||||
<Card className="flex flex-col border p-12 space-y-6 bg-background w-96">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submitOrgInfoForm)} className="space-y-8">
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
|
|
@ -76,9 +71,10 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
|
|||
<FormItem>
|
||||
<FormLabel>Organization Name</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Aperture Labs"
|
||||
{...field}
|
||||
<Input
|
||||
placeholder="Aperture Labs"
|
||||
{...field}
|
||||
autoFocus
|
||||
onChange={(e) => {
|
||||
field.onChange(e)
|
||||
handleNameChange(e)
|
||||
|
|
@ -105,12 +101,17 @@ export function OrgCreateForm({ setOrgCreateData }: OrgCreateFormProps) {
|
|||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{errorMessage && <p className="text-red-500">{errorMessage}</p>}
|
||||
<div className="flex justify-center">
|
||||
<Button type="submit">Create</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="default"
|
||||
className="w-full"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{isSubmitting && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
|
||||
Create
|
||||
</Button>
|
||||
</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";
|
||||
import { OrgCreateForm, OnboardingFormValues } from "./components/orgCreateForm";
|
||||
import { TrialCard } from "./components/trialInfoCard";
|
||||
import { isAuthed } from "@/actions";
|
||||
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]);
|
||||
export default async function Onboarding() {
|
||||
const session = await auth();
|
||||
if (!session) {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center items-center h-screen">
|
||||
{orgCreateInfo ? (
|
||||
<TrialCard orgCreateInfo={ orgCreateInfo } />
|
||||
) : (
|
||||
<div className="flex flex-col items-center border p-16 rounded-lg gap-6">
|
||||
<OrgCreateForm setOrgCreateData={setOrgInfo} />
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center min-h-screen p-12 bg-backgroundSecondary fade-in-20 relative">
|
||||
<OnboardHeader
|
||||
title="Setup your organization"
|
||||
description="Create a organization for your team to search and share code across your repositories."
|
||||
step={OnboardingSteps.CreateOrg}
|
||||
/>
|
||||
<OrgCreateForm />
|
||||
<LogoutEscapeHatch className="absolute top-0 right-0 p-12" />
|
||||
</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 (
|
||||
<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">
|
||||
|
|
|
|||
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',
|
||||
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
|
||||
INVALID_INVITE = 'INVALID_INVITE',
|
||||
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue