skip stripe checkout for trial + fix indexing in progress UI + additional schema validation (#214)

* add additional config validation

* wip bypass stripe checkout for trial

* fix stripe trial checkout bypass

* fix indexing in progress ui on home page

* add subscription checks, more schema validation, and fix issue with complete page

* dont display if no indexed repos
This commit is contained in:
Michael Sukkarieh 2025-02-26 17:29:09 -08:00 committed by GitHub
parent 386a3b52d7
commit 4869137d1e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 357 additions and 126 deletions

View file

@ -21,10 +21,11 @@ import { getUser } from "@/data/user";
import { Session } from "next-auth"; import { Session } from "next-auth";
import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL, AUTH_URL } from "@/lib/environment"; import { STRIPE_PRODUCT_ID, CONFIG_MAX_REPOS_NO_TOKEN, EMAIL_FROM, SMTP_CONNECTION_URL, AUTH_URL } from "@/lib/environment";
import Stripe from "stripe"; import Stripe from "stripe";
import { OnboardingSteps } from "./lib/constants";
import { render } from "@react-email/components"; import { render } from "@react-email/components";
import InviteUserEmail from "./emails/inviteUserEmail"; import InviteUserEmail from "./emails/inviteUserEmail";
import { createTransport } from "nodemailer"; import { createTransport } from "nodemailer";
import { repositoryQuerySchema } from "./lib/schemas";
import { RepositoryQuery } from "./lib/types";
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
@ -115,7 +116,7 @@ export const createOrg = (name: string, domain: string): Promise<{ id: number }
} }
}); });
export const completeOnboarding = async (stripeCheckoutSessionId: string, domain: string): Promise<{ success: boolean } | ServiceError> => export const completeOnboarding = async (domain: string): Promise<{ success: boolean } | ServiceError> =>
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({
@ -126,25 +127,9 @@ export const completeOnboarding = async (stripeCheckoutSessionId: string, domain
return notFound(); return notFound();
} }
const stripe = getStripe(); const subscription = await fetchSubscription(domain);
const stripeSession = await stripe.checkout.sessions.retrieve(stripeCheckoutSessionId); if (isServiceError(subscription)) {
const stripeCustomerId = stripeSession.customer as string; return subscription;
// 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({ await prisma.org.update({
@ -317,7 +302,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
}) })
) )
export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => export const getRepos = async (domain: string, filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}): Promise<RepositoryQuery[] | ServiceError> =>
withAuth((session) => withAuth((session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
const repos = await prisma.repo.findMany({ const repos = await prisma.repo.findMany({
@ -339,9 +324,11 @@ export const getRepos = async (domain: string, filter: { status?: RepoIndexingSt
} }
}); });
return repos.map((repo) => ({ return repos.map((repo) => repositoryQuerySchema.parse({
codeHostType: repo.external_codeHostType,
repoId: repo.id, repoId: repo.id,
repoName: repo.name, repoName: repo.name,
repoCloneUrl: repo.cloneUrl,
linkedConnections: repo.connections.map((connection) => connection.connectionId), linkedConnections: repo.connections.map((connection) => connection.connectionId),
imageUrl: repo.imageUrl ?? undefined, imageUrl: repo.imageUrl ?? undefined,
indexedAt: repo.indexedAt ?? undefined, indexedAt: repo.indexedAt ?? undefined,
@ -814,7 +801,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* minRequiredRole = */ OrgRole.OWNER)
); );
export const createOnboardingStripeCheckoutSession = async (domain: string) => export const createOnboardingSubscription = async (domain: string) =>
withAuth(async (session) => withAuth(async (session) =>
withOrgMembership(session, domain, async ({ orgId }) => { withOrgMembership(session, domain, async ({ orgId }) => {
const org = await prisma.org.findUnique({ const org = await prisma.org.findUnique({
@ -833,7 +820,6 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) =>
} }
const stripe = getStripe(); const stripe = getStripe();
const origin = (await headers()).get('origin');
// @nocheckin // @nocheckin
const test_clock = await stripe.testHelpers.testClocks.create({ const test_clock = await stripe.testHelpers.testClocks.create({
@ -865,45 +851,59 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) =>
return customer.id; return customer.id;
})(); })();
const existingSubscription = await fetchSubscription(domain);
if (existingSubscription && !isServiceError(existingSubscription)) {
return {
statusCode: StatusCodes.BAD_REQUEST,
errorCode: ErrorCode.SUBSCRIPTION_ALREADY_EXISTS,
message: "Attemped to create a trial subscription for an organization that already has an active subscription",
} satisfies ServiceError;
}
const prices = await stripe.prices.list({ const prices = await stripe.prices.list({
product: STRIPE_PRODUCT_ID, product: STRIPE_PRODUCT_ID,
expand: ['data.product'], expand: ['data.product'],
}); });
const stripeSession = await stripe.checkout.sessions.create({ try {
const subscription = await stripe.subscriptions.create({
customer: customerId, customer: customerId,
line_items: [ items: [{
{
price: prices.data[0].id, price: prices.data[0].id,
quantity: 1 }],
} trial_period_days: 14,
],
mode: 'subscription',
subscription_data: {
trial_period_days: 7,
trial_settings: { trial_settings: {
end_behavior: { end_behavior: {
missing_payment_method: 'cancel', missing_payment_method: 'cancel',
}, },
}, },
payment_settings: {
save_default_payment_method: 'on_subscription',
}, },
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}`,
}); });
if (!stripeSession.url) { if (!subscription) {
return { return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR, statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
message: "Failed to create checkout session", message: "Failed to create subscription",
} satisfies ServiceError; } satisfies ServiceError;
} }
return { return {
url: stripeSession.url, subscriptionId: subscription.id,
} }
} catch (e) {
console.error(e);
return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR,
message: "Failed to create subscription",
} satisfies ServiceError;
}
}, /* minRequiredRole = */ OrgRole.OWNER) }, /* minRequiredRole = */ OrgRole.OWNER)
); );

View file

@ -9,6 +9,22 @@ interface GerritConnectionCreationFormProps {
onCreated?: (id: number) => void; onCreated?: (id: number) => void;
} }
const additionalConfigValidation = (config: GerritConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0;
if (!hasProjects) {
return {
message: "At least one project must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
}
export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => { export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCreationFormProps) => {
const defaultConfig: GerritConnectionConfig = { const defaultConfig: GerritConnectionConfig = {
type: 'gerrit', type: 'gerrit',
@ -24,6 +40,7 @@ export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCrea
}} }}
schema={gerritSchema} schema={gerritSchema}
quickActions={gerritQuickActions} quickActions={gerritQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated} onCreated={onCreated}
/> />
) )

View file

@ -9,6 +9,24 @@ interface GiteaConnectionCreationFormProps {
onCreated?: (id: number) => void; onCreated?: (id: number) => void;
} }
const additionalConfigValidation = (config: GiteaConnectionConfig): { message: string, isValid: boolean } => {
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
if (!hasOrgs && !hasUsers && !hasRepos) {
return {
message: "At least one organization, user, or repository must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
}
export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => { export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreationFormProps) => {
const defaultConfig: GiteaConnectionConfig = { const defaultConfig: GiteaConnectionConfig = {
type: 'gitea', type: 'gitea',
@ -23,6 +41,7 @@ export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreati
}} }}
schema={giteaSchema} schema={giteaSchema}
quickActions={giteaQuickActions} quickActions={giteaQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated} onCreated={onCreated}
/> />
) )

View file

@ -9,6 +9,24 @@ interface GitHubConnectionCreationFormProps {
onCreated?: (id: number) => void; onCreated?: (id: number) => void;
} }
const additionalConfigValidation = (config: GithubConnectionConfig): { message: string, isValid: boolean } => {
const hasRepos = config.repos && config.repos.length > 0 && config.repos.some(r => r.trim().length > 0);
const hasOrgs = config.orgs && config.orgs.length > 0 && config.orgs.some(o => o.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
if (!hasRepos && !hasOrgs && !hasUsers) {
return {
message: "At least one repository, organization, or user must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
};
export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => { export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCreationFormProps) => {
const defaultConfig: GithubConnectionConfig = { const defaultConfig: GithubConnectionConfig = {
type: 'github', type: 'github',
@ -22,6 +40,7 @@ export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCrea
config: JSON.stringify(defaultConfig, null, 2), config: JSON.stringify(defaultConfig, null, 2),
}} }}
schema={githubSchema} schema={githubSchema}
additionalConfigValidation={additionalConfigValidation}
quickActions={githubQuickActions} quickActions={githubQuickActions}
onCreated={onCreated} onCreated={onCreated}
/> />

View file

@ -9,6 +9,24 @@ interface GitLabConnectionCreationFormProps {
onCreated?: (id: number) => void; onCreated?: (id: number) => void;
} }
const additionalConfigValidation = (config: GitlabConnectionConfig): { message: string, isValid: boolean } => {
const hasProjects = config.projects && config.projects.length > 0 && config.projects.some(p => p.trim().length > 0);
const hasUsers = config.users && config.users.length > 0 && config.users.some(u => u.trim().length > 0);
const hasGroups = config.groups && config.groups.length > 0 && config.groups.some(g => g.trim().length > 0);
if (!hasProjects && !hasUsers && !hasGroups) {
return {
message: "At least one project, user, or group must be specified",
isValid: false,
}
}
return {
message: "Valid",
isValid: true,
}
}
export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => { export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCreationFormProps) => {
const defaultConfig: GitlabConnectionConfig = { const defaultConfig: GitlabConnectionConfig = {
type: 'gitlab', type: 'gitlab',
@ -23,6 +41,7 @@ export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCrea
}} }}
schema={gitlabSchema} schema={gitlabSchema}
quickActions={gitlabQuickActions} quickActions={gitlabQuickActions}
additionalConfigValidation={additionalConfigValidation}
onCreated={onCreated} onCreated={onCreated}
/> />
) )

View file

@ -37,6 +37,7 @@ interface SharedConnectionCreationFormProps<T> {
}[], }[],
className?: string; className?: string;
onCreated?: (id: number) => void; onCreated?: (id: number) => void;
additionalConfigValidation?: (config: T) => { message: string, isValid: boolean };
} }
@ -48,6 +49,7 @@ export default function SharedConnectionCreationForm<T>({
quickActions, quickActions,
className, className,
onCreated, onCreated,
additionalConfigValidation
}: SharedConnectionCreationFormProps<T>) { }: SharedConnectionCreationFormProps<T>) {
const { toast } = useToast(); const { toast } = useToast();
const domain = useDomain(); const domain = useDomain();
@ -56,7 +58,7 @@ export default function SharedConnectionCreationForm<T>({
const formSchema = useMemo(() => { const formSchema = useMemo(() => {
return z.object({ return z.object({
name: z.string().min(1), name: z.string().min(1),
config: createZodConnectionConfigValidator(schema), config: createZodConnectionConfigValidator(schema, additionalConfigValidation),
secretKey: z.string().optional().refine(async (secretKey) => { secretKey: z.string().optional().refine(async (secretKey) => {
if (!secretKey) { if (!secretKey) {
return true; return true;

View file

@ -6,14 +6,14 @@ import {
CarouselItem, CarouselItem,
} from "@/components/ui/carousel"; } from "@/components/ui/carousel";
import Autoscroll from "embla-carousel-auto-scroll"; import Autoscroll from "embla-carousel-auto-scroll";
import { getRepoCodeHostInfo } from "@/lib/utils"; import { getRepoQueryCodeHostInfo } from "@/lib/utils";
import Image from "next/image"; import Image from "next/image";
import { FileIcon } from "@radix-ui/react-icons"; import { FileIcon } from "@radix-ui/react-icons";
import clsx from "clsx"; import clsx from "clsx";
import { Repository } from "@/lib/types"; import { RepositoryQuery } from "@/lib/types";
interface RepositoryCarouselProps { interface RepositoryCarouselProps {
repos: Repository[]; repos: RepositoryQuery[];
} }
export const RepositoryCarousel = ({ export const RepositoryCarousel = ({
@ -50,14 +50,14 @@ export const RepositoryCarousel = ({
}; };
interface RepositoryBadgeProps { interface RepositoryBadgeProps {
repo: Repository; repo: RepositoryQuery;
} }
const RepositoryBadge = ({ const RepositoryBadge = ({
repo repo
}: RepositoryBadgeProps) => { }: RepositoryBadgeProps) => {
const { repoIcon, displayName, repoLink } = (() => { const { repoIcon, displayName, repoLink } = (() => {
const info = getRepoCodeHostInfo(repo); const info = getRepoQueryCodeHostInfo(repo);
if (info) { if (info) {
return { return {
@ -73,7 +73,7 @@ const RepositoryBadge = ({
return { return {
repoIcon: <FileIcon className="w-4 h-4" />, repoIcon: <FileIcon className="w-4 h-4" />,
displayName: repo.Name, displayName: repo.repoName.split('/').slice(-2).join('/'),
repoLink: undefined, repoLink: undefined,
} }
})(); })();

View file

@ -0,0 +1,122 @@
"use client";
import Link from "next/link";
import { RepositoryCarousel } from "./repositoryCarousel";
import { useDomain } from "@/hooks/useDomain";
import { useQuery } from "@tanstack/react-query";
import { unwrapServiceError } from "@/lib/utils";
import { getRepos } from "@/actions";
import { NEXT_PUBLIC_POLLING_INTERVAL_MS } from "@/lib/environment.client";
import { Skeleton } from "@/components/ui/skeleton";
import {
Carousel,
CarouselContent,
CarouselItem,
} from "@/components/ui/carousel";
import { Plus } from "lucide-react";
import { RepoIndexingStatus } from "@sourcebot/db";
import { SymbolIcon } from "@radix-ui/react-icons";
export function EmptyRepoState({ domain }: { domain: string }) {
return (
<div className="flex flex-col items-center gap-3">
<span className="text-sm">No repositories found</span>
<div className="w-full max-w-lg">
<div className="flex flex-row items-center gap-2 border rounded-md p-4 justify-center">
<span className="text-sm text-muted-foreground">
Create a{" "}
<Link href={`/${domain}/connections`} className="text-blue-500 hover:underline inline-flex items-center gap-1">
connection
</Link>{" "}
to start indexing repositories
</span>
</div>
</div>
</div>
)
}
export default function RepoSkeleton() {
return (
<div className="flex flex-col items-center gap-3">
{/* Skeleton for "Search X repositories" text */}
<div className="flex items-center gap-1 text-sm">
<Skeleton className="h-4 w-14" /> {/* "Search X" */}
<Skeleton className="h-4 w-24" /> {/* "repositories" */}
</div>
{/* Skeleton for repository carousel */}
<Carousel
opts={{
align: "start",
loop: true,
}}
className="w-full max-w-lg"
>
<CarouselContent>
{[1, 2, 3].map((_, index) => (
<CarouselItem key={index} className="basis-auto">
<div className="flex flex-row items-center gap-2 border rounded-md p-2">
<Skeleton className="h-4 w-4 rounded-sm" /> {/* Icon */}
<Skeleton className="h-4 w-32" /> {/* Repository name */}
</div>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</div>
)
}
export function RepositorySnapshot() {
const domain = useDomain();
const { data: repos, isPending, isError } = useQuery({
queryKey: ['repos', domain],
queryFn: () => unwrapServiceError(getRepos(domain)),
refetchInterval: NEXT_PUBLIC_POLLING_INTERVAL_MS,
});
if (isPending || isError || !repos) {
return (
<div className="flex flex-col items-center gap-3">
<RepoSkeleton />
</div>
)
}
const indexedRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXED);
if (repos.length === 0 || indexedRepos.length === 0) {
return (
<EmptyRepoState domain={domain} />
)
}
const numIndexedRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXED).length;
const numIndexingRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXING || repo.repoIndexingStatus === RepoIndexingStatus.IN_INDEX_QUEUE).length;
if (numIndexedRepos === 0 && numIndexingRepos > 0) {
return (
<div className="flex flex-row items-center gap-3">
<SymbolIcon className="h-4 w-4 animate-spin" />
<span className="text-sm">indexing in progress...</span>
</div>
)
}
return (
<div className="flex flex-col items-center gap-3">
<span className="text-sm">
{`Search ${indexedRepos.length} `}
<Link
href={`${domain}/repos`}
className="text-blue-500"
>
{repos.length > 1 ? 'repositories' : 'repository'}
</Link>
</span>
<RepositoryCarousel repos={indexedRepos} />
</div>
)
}

View file

@ -1,7 +1,7 @@
import Ajv, { Schema } from "ajv"; import Ajv, { Schema } from "ajv";
import { z } from "zod"; import { z } from "zod";
export const createZodConnectionConfigValidator = (jsonSchema: Schema) => { export const createZodConnectionConfigValidator = <T>(jsonSchema: Schema, additionalConfigValidation?: (config: T) => { message: string, isValid: boolean }) => {
const ajv = new Ajv({ const ajv = new Ajv({
validateFormats: false, validateFormats: false,
}); });
@ -29,5 +29,12 @@ export const createZodConnectionConfigValidator = (jsonSchema: Schema) => {
if (!valid) { if (!valid) {
addIssue(ajv.errorsText(validate.errors)); addIssue(ajv.errorsText(validate.errors));
} }
if (additionalConfigValidation) {
const result = additionalConfigValidation(parsed as T);
if (!result.isValid) {
addIssue(result.message);
}
}
}); });
} }

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { createOnboardingStripeCheckoutSession } from "@/actions"; import { createOnboardingSubscription } from "@/actions";
import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { SourcebotLogo } from "@/app/components/sourcebotLogo";
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";
@ -11,7 +11,7 @@ import { isServiceError } from "@/lib/utils";
import { Check, Loader2 } from "lucide-react"; import { Check, Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { TEAM_FEATURES } from "@/lib/constants"; import { OnboardingSteps, TEAM_FEATURES } from "@/lib/constants";
import useCaptureEvent from "@/hooks/useCaptureEvent"; import useCaptureEvent from "@/hooks/useCaptureEvent";
export const Checkout = () => { export const Checkout = () => {
@ -37,7 +37,7 @@ export const Checkout = () => {
const onCheckout = useCallback(() => { const onCheckout = useCallback(() => {
setIsLoading(true); setIsLoading(true);
createOnboardingStripeCheckoutSession(domain) createOnboardingSubscription(domain)
.then((response) => { .then((response) => {
if (isServiceError(response)) { if (isServiceError(response)) {
toast({ toast({
@ -48,8 +48,8 @@ export const Checkout = () => {
error: response.errorCode, error: response.errorCode,
}); });
} else { } else {
router.push(response.url);
captureEvent('wa_onboard_checkout_success', {}); captureEvent('wa_onboard_checkout_success', {});
router.push(`/${domain}/onboard?step=${OnboardingSteps.Complete}`);
} }
}) })
.finally(() => { .finally(() => {
@ -63,7 +63,7 @@ export const Checkout = () => {
className="h-16" className="h-16"
size="large" size="large"
/> />
<h1 className="text-2xl font-semibold">Start your 7 day free trial</h1> <h1 className="text-2xl font-semibold">Start your 14 day free trial</h1>
<p className="text-muted-foreground mt-2">Cancel anytime. No credit card required.</p> <p className="text-muted-foreground mt-2">Cancel anytime. No credit card required.</p>
<ul className="space-y-4 mb-6 mt-10"> <ul className="space-y-4 mb-6 mt-10">
{TEAM_FEATURES.map((feature, index) => ( {TEAM_FEATURES.map((feature, index) => (

View file

@ -1,27 +1,30 @@
'use client';
import { completeOnboarding } from "@/actions"; import { completeOnboarding } from "@/actions";
import { OnboardingSteps } from "@/lib/constants"; import { OnboardingSteps } from "@/lib/constants";
import { isServiceError } from "@/lib/utils"; import { isServiceError } from "@/lib/utils";
import { redirect } from "next/navigation"; import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { useDomain } from "@/hooks/useDomain";
interface CompleteOnboardingProps { export const CompleteOnboarding = () => {
searchParams: { const router = useRouter();
stripe_session_id?: string; const domain = useDomain();
}
params: {
domain: string;
}
}
export const CompleteOnboarding = async ({ searchParams, params: { domain } }: CompleteOnboardingProps) => { useEffect(() => {
if (!searchParams.stripe_session_id) { const complete = async () => {
return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}`); const response = await completeOnboarding(domain);
}
const { stripe_session_id } = searchParams;
const response = await completeOnboarding(stripe_session_id, domain);
if (isServiceError(response)) { if (isServiceError(response)) {
return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`); router.push(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`);
return;
} }
return redirect(`/${domain}`); router.push(`/${domain}`);
router.refresh();
};
complete();
}, [domain, router]);
return null;
} }

View file

@ -7,7 +7,6 @@ import { InviteTeam } from "./components/inviteTeam";
import { CompleteOnboarding } from "./components/completeOnboarding"; import { CompleteOnboarding } from "./components/completeOnboarding";
import { Checkout } from "./components/checkout"; import { Checkout } from "./components/checkout";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { SkipOnboardingButton } from "./components/skipOnboardingButton";
interface OnboardProps { interface OnboardProps {
params: { params: {
domain: string domain: string
@ -54,10 +53,6 @@ export default async function Onboard({ params, searchParams }: OnboardProps) {
<ConnectCodeHost <ConnectCodeHost
nextStep={OnboardingSteps.InviteTeam} nextStep={OnboardingSteps.InviteTeam}
/> />
<SkipOnboardingButton
currentStep={step as OnboardingSteps}
lastRequiredStep={lastRequiredStep}
/>
</> </>
)} )}
{step === OnboardingSteps.InviteTeam && ( {step === OnboardingSteps.InviteTeam && (

View file

@ -12,7 +12,7 @@ import { getOrgFromDomain } from "@/data/org";
import { PageNotFound } from "./components/pageNotFound"; import { PageNotFound } from "./components/pageNotFound";
import { Footer } from "./components/footer"; import { Footer } from "./components/footer";
import { SourcebotLogo } from "../components/sourcebotLogo"; import { SourcebotLogo } from "../components/sourcebotLogo";
import { RepositorySnapshot } from "./components/repositorySnapshot";
export default async function Home({ params: { domain } }: { params: { domain: string } }) { export default async function Home({ params: { domain } }: { params: { domain: string } }) {
const org = await getOrgFromDomain(domain); const org = await getOrgFromDomain(domain);
@ -38,10 +38,7 @@ export default async function Home({ params: { domain } }: { params: { domain: s
/> />
<div className="mt-8"> <div className="mt-8">
<Suspense fallback={<div>...</div>}> <Suspense fallback={<div>...</div>}>
<RepositoryList <RepositorySnapshot />
orgId={org.id}
domain={domain}
/>
</Suspense> </Suspense>
</div> </div>
<div className="flex flex-col items-center w-fit gap-6"> <div className="flex flex-col items-center w-fit gap-6">
@ -104,40 +101,6 @@ export default async function Home({ params: { domain } }: { params: { domain: s
) )
} }
const RepositoryList = async ({ orgId, domain }: { orgId: number, domain: string }) => {
const _repos = await listRepositories(orgId);
if (isServiceError(_repos)) {
return null;
}
const repos = _repos.List.Repos.map((repo) => repo.Repository);
if (repos.length === 0) {
return (
<div className="flex flex-row items-center gap-3">
<SymbolIcon className="h-4 w-4 animate-spin" />
<span className="text-sm">indexing in progress...</span>
</div>
)
}
return (
<div className="flex flex-col items-center gap-3">
<span className="text-sm">
{`Search ${repos.length} `}
<Link
href={`${domain}/repos`}
className="text-blue-500"
>
{repos.length > 1 ? 'repositories' : 'repository'}
</Link>
</span>
<RepositoryCarousel repos={repos} />
</div>
)
}
const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => { const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => {
return ( return (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">

View file

@ -18,4 +18,5 @@ export enum ErrorCode {
INVALID_INVITE = 'INVALID_INVITE', INVALID_INVITE = 'INVALID_INVITE',
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR', STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS', SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS',
} }

View file

@ -1,5 +1,5 @@
import { RepoIndexingStatus } from "@sourcebot/db";
import { z } from "zod"; import { z } from "zod";
export const searchRequestSchema = z.object({ export const searchRequestSchema = z.object({
query: z.string(), query: z.string(),
maxMatchDisplayCount: z.number(), maxMatchDisplayCount: z.number(),
@ -162,6 +162,16 @@ export const listRepositoriesResponseSchema = z.object({
Stats: repoStatsSchema, Stats: repoStatsSchema,
}) })
}); });
export const repositoryQuerySchema = z.object({
codeHostType: z.string(),
repoId: z.number(),
repoName: z.string(),
repoCloneUrl: z.string(),
linkedConnections: z.array(z.number()),
imageUrl: z.string().optional(),
indexedAt: z.date().optional(),
repoIndexingStatus: z.nativeEnum(RepoIndexingStatus),
});
export const verifyCredentialsRequestSchema = z.object({ export const verifyCredentialsRequestSchema = z.object({
email: z.string().email(), email: z.string().email(),

View file

@ -1,5 +1,5 @@
import { z } from "zod"; import { z } from "zod";
import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, searchRequestSchema, searchResponseSchema, symbolSchema } from "./schemas"; import { fileSourceRequestSchema, fileSourceResponseSchema, listRepositoriesResponseSchema, locationSchema, rangeSchema, repositorySchema, repositoryQuerySchema, searchRequestSchema, searchResponseSchema, symbolSchema } from "./schemas";
export type KeymapType = "default" | "vim"; export type KeymapType = "default" | "vim";
@ -17,7 +17,7 @@ export type FileSourceResponse = z.infer<typeof fileSourceResponseSchema>;
export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>; export type ListRepositoriesResponse = z.infer<typeof listRepositoriesResponseSchema>;
export type Repository = z.infer<typeof repositorySchema>; export type Repository = z.infer<typeof repositorySchema>;
export type RepositoryQuery = z.infer<typeof repositoryQuerySchema>;
export type Symbol = z.infer<typeof symbolSchema>; export type Symbol = z.infer<typeof symbolSchema>;
export enum SearchQueryParams { export enum SearchQueryParams {

View file

@ -5,7 +5,7 @@ import gitlabLogo from "@/public/gitlab.svg";
import giteaLogo from "@/public/gitea.svg"; import giteaLogo from "@/public/gitea.svg";
import gerritLogo from "@/public/gerrit.svg"; import gerritLogo from "@/public/gerrit.svg";
import { ServiceError } from "./serviceError"; import { ServiceError } from "./serviceError";
import { Repository } from "./types"; import { Repository, RepositoryQuery } from "./types";
export function cn(...inputs: ClassValue[]) { export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs)) return twMerge(clsx(inputs))
@ -102,6 +102,60 @@ export const getRepoCodeHostInfo = (repo?: Repository): CodeHostInfo | undefined
} }
} }
export const getRepoQueryCodeHostInfo = (repo?: RepositoryQuery): CodeHostInfo | undefined => {
if (!repo) {
return undefined;
}
const displayName = repo.repoName.split('/').slice(-2).join('/');
switch (repo.codeHostType) {
case 'github': {
const { src, className } = getCodeHostIcon('github')!;
return {
type: "github",
displayName: displayName,
costHostName: "GitHub",
repoLink: repo.repoCloneUrl,
icon: src,
iconClassName: className,
}
}
case 'gitlab': {
const { src, className } = getCodeHostIcon('gitlab')!;
return {
type: "gitlab",
displayName: displayName,
costHostName: "GitLab",
repoLink: repo.repoCloneUrl,
icon: src,
iconClassName: className,
}
}
case 'gitea': {
const { src, className } = getCodeHostIcon('gitea')!;
return {
type: "gitea",
displayName: displayName,
costHostName: "Gitea",
repoLink: repo.repoCloneUrl,
icon: src,
iconClassName: className,
}
}
case 'gitiles': {
const { src, className } = getCodeHostIcon('gerrit')!;
return {
type: "gerrit",
displayName: displayName,
costHostName: "Gerrit",
repoLink: repo.repoCloneUrl,
icon: src,
iconClassName: className,
}
}
}
}
export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } | null => { export const getCodeHostIcon = (codeHostType: CodeHostType): { src: string, className?: string } | null => {
switch (codeHostType) { switch (codeHostType) {
case "github": case "github":