diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index e3c90d01..9f764e8d 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -21,10 +21,11 @@ import { getUser } from "@/data/user"; 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 from "stripe"; -import { OnboardingSteps } from "./lib/constants"; import { render } from "@react-email/components"; import InviteUserEmail from "./emails/inviteUserEmail"; import { createTransport } from "nodemailer"; +import { repositoryQuerySchema } from "./lib/schemas"; +import { RepositoryQuery } from "./lib/types"; const ajv = new Ajv({ 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) => withOrgMembership(session, domain, async ({ orgId }) => { const org = await prisma.org.findUnique({ @@ -126,25 +127,9 @@ export const completeOnboarding = async (stripeCheckoutSessionId: string, domain 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; + const subscription = await fetchSubscription(domain); + if (isServiceError(subscription)) { + return subscription; } await prisma.org.update({ @@ -161,7 +146,7 @@ export const completeOnboarding = async (stripeCheckoutSessionId: string, domain } }) ); - + export const getSecrets = (domain: string): Promise<{ createdAt: Date; key: string; }[] | ServiceError> => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { @@ -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 => withAuth((session) => withOrgMembership(session, domain, async ({ orgId }) => { 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, repoName: repo.name, + repoCloneUrl: repo.cloneUrl, linkedConnections: repo.connections.map((connection) => connection.connectionId), imageUrl: repo.imageUrl ?? undefined, indexedAt: repo.indexedAt ?? undefined, @@ -814,7 +801,7 @@ export const transferOwnership = async (newOwnerId: string, domain: string): Pro }, /* minRequiredRole = */ OrgRole.OWNER) ); -export const createOnboardingStripeCheckoutSession = async (domain: string) => +export const createOnboardingSubscription = async (domain: string) => withAuth(async (session) => withOrgMembership(session, domain, async ({ orgId }) => { const org = await prisma.org.findUnique({ @@ -833,7 +820,6 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) => } const stripe = getStripe(); - const origin = (await headers()).get('origin'); // @nocheckin const test_clock = await stripe.testHelpers.testClocks.create({ @@ -865,45 +851,59 @@ export const createOnboardingStripeCheckoutSession = async (domain: string) => 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({ product: STRIPE_PRODUCT_ID, expand: ['data.product'], }); - const stripeSession = await stripe.checkout.sessions.create({ - customer: customerId, - line_items: [ - { + try { + const subscription = await stripe.subscriptions.create({ + customer: customerId, + items: [{ price: prices.data[0].id, - quantity: 1 - } - ], - mode: 'subscription', - subscription_data: { - trial_period_days: 7, + }], + trial_period_days: 14, trial_settings: { end_behavior: { missing_payment_method: 'cancel', }, }, - }, - 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}`, - }); + payment_settings: { + save_default_payment_method: 'on_subscription', + }, + }); + + if (!subscription) { + return { + statusCode: StatusCodes.INTERNAL_SERVER_ERROR, + errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, + message: "Failed to create subscription", + } satisfies ServiceError; + } - if (!stripeSession.url) { + return { + subscriptionId: subscription.id, + } + } catch (e) { + console.error(e); return { statusCode: StatusCodes.INTERNAL_SERVER_ERROR, errorCode: ErrorCode.STRIPE_CHECKOUT_ERROR, - message: "Failed to create checkout session", + message: "Failed to create subscription", } satisfies ServiceError; } - return { - url: stripeSession.url, - } + }, /* minRequiredRole = */ OrgRole.OWNER) ); diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx index e910c93d..64b6b10b 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/gerritConnectionCreationForm.tsx @@ -9,6 +9,22 @@ interface GerritConnectionCreationFormProps { 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) => { const defaultConfig: GerritConnectionConfig = { type: 'gerrit', @@ -24,6 +40,7 @@ export const GerritConnectionCreationForm = ({ onCreated }: GerritConnectionCrea }} schema={gerritSchema} quickActions={gerritQuickActions} + additionalConfigValidation={additionalConfigValidation} onCreated={onCreated} /> ) diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx index 4df4797a..f19c441c 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/giteaConnectionCreationForm.tsx @@ -9,6 +9,24 @@ interface GiteaConnectionCreationFormProps { 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) => { const defaultConfig: GiteaConnectionConfig = { type: 'gitea', @@ -23,6 +41,7 @@ export const GiteaConnectionCreationForm = ({ onCreated }: GiteaConnectionCreati }} schema={giteaSchema} quickActions={giteaQuickActions} + additionalConfigValidation={additionalConfigValidation} onCreated={onCreated} /> ) diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx index 64e4d3f9..80446ab4 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/githubConnectionCreationForm.tsx @@ -9,6 +9,24 @@ interface GitHubConnectionCreationFormProps { 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) => { const defaultConfig: GithubConnectionConfig = { type: 'github', @@ -22,6 +40,7 @@ export const GitHubConnectionCreationForm = ({ onCreated }: GitHubConnectionCrea config: JSON.stringify(defaultConfig, null, 2), }} schema={githubSchema} + additionalConfigValidation={additionalConfigValidation} quickActions={githubQuickActions} onCreated={onCreated} /> diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx index 77508f24..d21823f6 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/gitlabConnectionCreationForm.tsx @@ -9,6 +9,24 @@ interface GitLabConnectionCreationFormProps { 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) => { const defaultConfig: GitlabConnectionConfig = { type: 'gitlab', @@ -23,6 +41,7 @@ export const GitLabConnectionCreationForm = ({ onCreated }: GitLabConnectionCrea }} schema={gitlabSchema} quickActions={gitlabQuickActions} + additionalConfigValidation={additionalConfigValidation} onCreated={onCreated} /> ) diff --git a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx index 5fa2f94f..63e9c006 100644 --- a/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx +++ b/packages/web/src/app/[domain]/components/connectionCreationForms/sharedConnectionCreationForm.tsx @@ -37,6 +37,7 @@ interface SharedConnectionCreationFormProps { }[], className?: string; onCreated?: (id: number) => void; + additionalConfigValidation?: (config: T) => { message: string, isValid: boolean }; } @@ -48,6 +49,7 @@ export default function SharedConnectionCreationForm({ quickActions, className, onCreated, + additionalConfigValidation }: SharedConnectionCreationFormProps) { const { toast } = useToast(); const domain = useDomain(); @@ -56,7 +58,7 @@ export default function SharedConnectionCreationForm({ const formSchema = useMemo(() => { return z.object({ name: z.string().min(1), - config: createZodConnectionConfigValidator(schema), + config: createZodConnectionConfigValidator(schema, additionalConfigValidation), secretKey: z.string().optional().refine(async (secretKey) => { if (!secretKey) { return true; diff --git a/packages/web/src/app/[domain]/components/repositoryCarousel.tsx b/packages/web/src/app/[domain]/components/repositoryCarousel.tsx index 3d6fa8a2..038b9bbf 100644 --- a/packages/web/src/app/[domain]/components/repositoryCarousel.tsx +++ b/packages/web/src/app/[domain]/components/repositoryCarousel.tsx @@ -6,14 +6,14 @@ import { CarouselItem, } from "@/components/ui/carousel"; import Autoscroll from "embla-carousel-auto-scroll"; -import { getRepoCodeHostInfo } from "@/lib/utils"; +import { getRepoQueryCodeHostInfo } from "@/lib/utils"; import Image from "next/image"; import { FileIcon } from "@radix-ui/react-icons"; import clsx from "clsx"; -import { Repository } from "@/lib/types"; +import { RepositoryQuery } from "@/lib/types"; interface RepositoryCarouselProps { - repos: Repository[]; + repos: RepositoryQuery[]; } export const RepositoryCarousel = ({ @@ -50,14 +50,14 @@ export const RepositoryCarousel = ({ }; interface RepositoryBadgeProps { - repo: Repository; + repo: RepositoryQuery; } const RepositoryBadge = ({ repo }: RepositoryBadgeProps) => { const { repoIcon, displayName, repoLink } = (() => { - const info = getRepoCodeHostInfo(repo); + const info = getRepoQueryCodeHostInfo(repo); if (info) { return { @@ -73,7 +73,7 @@ const RepositoryBadge = ({ return { repoIcon: , - displayName: repo.Name, + displayName: repo.repoName.split('/').slice(-2).join('/'), repoLink: undefined, } })(); diff --git a/packages/web/src/app/[domain]/components/repositorySnapshot.tsx b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx new file mode 100644 index 00000000..0269fe3c --- /dev/null +++ b/packages/web/src/app/[domain]/components/repositorySnapshot.tsx @@ -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 ( +
+ No repositories found + +
+
+ + Create a{" "} + + connection + {" "} + to start indexing repositories + +
+
+
+ ) + } + +export default function RepoSkeleton() { + return ( +
+ {/* Skeleton for "Search X repositories" text */} +
+ {/* "Search X" */} + {/* "repositories" */} +
+ + {/* Skeleton for repository carousel */} + + + {[1, 2, 3].map((_, index) => ( + +
+ {/* Icon */} + {/* Repository name */} +
+
+ ))} +
+
+
+ ) + } + + +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 ( +
+ +
+ ) + } + + const indexedRepos = repos.filter((repo) => repo.repoIndexingStatus === RepoIndexingStatus.INDEXED); + if (repos.length === 0 || indexedRepos.length === 0) { + return ( + + ) + } + + 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 ( +
+ + indexing in progress... +
+ ) + } + + return ( +
+ + {`Search ${indexedRepos.length} `} + + {repos.length > 1 ? 'repositories' : 'repository'} + + + +
+ ) +} \ No newline at end of file diff --git a/packages/web/src/app/[domain]/connections/utils.ts b/packages/web/src/app/[domain]/connections/utils.ts index 2fbe552d..cd728092 100644 --- a/packages/web/src/app/[domain]/connections/utils.ts +++ b/packages/web/src/app/[domain]/connections/utils.ts @@ -1,7 +1,7 @@ import Ajv, { Schema } from "ajv"; import { z } from "zod"; -export const createZodConnectionConfigValidator = (jsonSchema: Schema) => { +export const createZodConnectionConfigValidator = (jsonSchema: Schema, additionalConfigValidation?: (config: T) => { message: string, isValid: boolean }) => { const ajv = new Ajv({ validateFormats: false, }); @@ -29,5 +29,12 @@ export const createZodConnectionConfigValidator = (jsonSchema: Schema) => { if (!valid) { addIssue(ajv.errorsText(validate.errors)); } + + if (additionalConfigValidation) { + const result = additionalConfigValidation(parsed as T); + if (!result.isValid) { + addIssue(result.message); + } + } }); } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/components/checkout.tsx b/packages/web/src/app/[domain]/onboard/components/checkout.tsx index 1d46ba4b..d38032a7 100644 --- a/packages/web/src/app/[domain]/onboard/components/checkout.tsx +++ b/packages/web/src/app/[domain]/onboard/components/checkout.tsx @@ -1,6 +1,6 @@ 'use client'; -import { createOnboardingStripeCheckoutSession } from "@/actions"; +import { createOnboardingSubscription } from "@/actions"; import { SourcebotLogo } from "@/app/components/sourcebotLogo"; import { useToast } from "@/components/hooks/use-toast"; import { Button } from "@/components/ui/button"; @@ -11,7 +11,7 @@ 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"; +import { OnboardingSteps, TEAM_FEATURES } from "@/lib/constants"; import useCaptureEvent from "@/hooks/useCaptureEvent"; export const Checkout = () => { @@ -37,7 +37,7 @@ export const Checkout = () => { const onCheckout = useCallback(() => { setIsLoading(true); - createOnboardingStripeCheckoutSession(domain) + createOnboardingSubscription(domain) .then((response) => { if (isServiceError(response)) { toast({ @@ -48,8 +48,8 @@ export const Checkout = () => { error: response.errorCode, }); } else { - router.push(response.url); captureEvent('wa_onboard_checkout_success', {}); + router.push(`/${domain}/onboard?step=${OnboardingSteps.Complete}`); } }) .finally(() => { @@ -63,7 +63,7 @@ export const Checkout = () => { className="h-16" size="large" /> -

Start your 7 day free trial

+

Start your 14 day free trial

Cancel anytime. No credit card required.

    {TEAM_FEATURES.map((feature, index) => ( diff --git a/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx b/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx index 267a5ecc..a0df45d2 100644 --- a/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx +++ b/packages/web/src/app/[domain]/onboard/components/completeOnboarding.tsx @@ -1,27 +1,30 @@ +'use client'; + import { completeOnboarding } from "@/actions"; import { OnboardingSteps } from "@/lib/constants"; import { isServiceError } from "@/lib/utils"; -import { redirect } from "next/navigation"; +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useDomain } from "@/hooks/useDomain"; + +export const CompleteOnboarding = () => { + const router = useRouter(); + const domain = useDomain(); -interface CompleteOnboardingProps { - searchParams: { - stripe_session_id?: string; - } - params: { - domain: string; - } -} + useEffect(() => { + const complete = async () => { + const response = await completeOnboarding(domain); + if (isServiceError(response)) { + router.push(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`); + return; + } -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; + router.push(`/${domain}`); + router.refresh(); + }; - const response = await completeOnboarding(stripe_session_id, domain); - if (isServiceError(response)) { - return redirect(`/${domain}/onboard?step=${OnboardingSteps.Checkout}&errorCode=${response.errorCode}&errorMessage=${response.message}`); - } + complete(); + }, [domain, router]); - return redirect(`/${domain}`); + return null; } \ No newline at end of file diff --git a/packages/web/src/app/[domain]/onboard/page.tsx b/packages/web/src/app/[domain]/onboard/page.tsx index fd277e34..b609282d 100644 --- a/packages/web/src/app/[domain]/onboard/page.tsx +++ b/packages/web/src/app/[domain]/onboard/page.tsx @@ -7,7 +7,6 @@ import { InviteTeam } from "./components/inviteTeam"; import { CompleteOnboarding } from "./components/completeOnboarding"; import { Checkout } from "./components/checkout"; import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch"; -import { SkipOnboardingButton } from "./components/skipOnboardingButton"; interface OnboardProps { params: { domain: string @@ -54,10 +53,6 @@ export default async function Onboard({ params, searchParams }: OnboardProps) { - )} {step === OnboardingSteps.InviteTeam && ( diff --git a/packages/web/src/app/[domain]/page.tsx b/packages/web/src/app/[domain]/page.tsx index 0f8304be..13ab35bd 100644 --- a/packages/web/src/app/[domain]/page.tsx +++ b/packages/web/src/app/[domain]/page.tsx @@ -12,7 +12,7 @@ import { getOrgFromDomain } from "@/data/org"; import { PageNotFound } from "./components/pageNotFound"; import { Footer } from "./components/footer"; import { SourcebotLogo } from "../components/sourcebotLogo"; - +import { RepositorySnapshot } from "./components/repositorySnapshot"; export default async function Home({ params: { domain } }: { params: { domain: string } }) { const org = await getOrgFromDomain(domain); @@ -38,10 +38,7 @@ export default async function Home({ params: { domain } }: { params: { domain: s />
    ...
    }> - +
    @@ -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 ( -
    - - indexing in progress... -
    - ) - } - - return ( -
    - - {`Search ${repos.length} `} - - {repos.length > 1 ? 'repositories' : 'repository'} - - - -
    - ) -} - const HowToSection = ({ title, children }: { title: string, children: React.ReactNode }) => { return (
    diff --git a/packages/web/src/lib/errorCodes.ts b/packages/web/src/lib/errorCodes.ts index 38144a4d..8adff8a4 100644 --- a/packages/web/src/lib/errorCodes.ts +++ b/packages/web/src/lib/errorCodes.ts @@ -18,4 +18,5 @@ export enum ErrorCode { INVALID_INVITE = 'INVALID_INVITE', STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR', SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS', + SUBSCRIPTION_ALREADY_EXISTS = 'SUBSCRIPTION_ALREADY_EXISTS', } diff --git a/packages/web/src/lib/schemas.ts b/packages/web/src/lib/schemas.ts index 06ae5b3e..1a72adf5 100644 --- a/packages/web/src/lib/schemas.ts +++ b/packages/web/src/lib/schemas.ts @@ -1,5 +1,5 @@ +import { RepoIndexingStatus } from "@sourcebot/db"; import { z } from "zod"; - export const searchRequestSchema = z.object({ query: z.string(), maxMatchDisplayCount: z.number(), @@ -162,6 +162,16 @@ export const listRepositoriesResponseSchema = z.object({ 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({ email: z.string().email(), diff --git a/packages/web/src/lib/types.ts b/packages/web/src/lib/types.ts index c1d8bccd..d2cd5aaa 100644 --- a/packages/web/src/lib/types.ts +++ b/packages/web/src/lib/types.ts @@ -1,5 +1,5 @@ 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"; @@ -17,7 +17,7 @@ export type FileSourceResponse = z.infer; export type ListRepositoriesResponse = z.infer; export type Repository = z.infer; - +export type RepositoryQuery = z.infer; export type Symbol = z.infer; export enum SearchQueryParams { diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts index 7d016aa8..9ec548cd 100644 --- a/packages/web/src/lib/utils.ts +++ b/packages/web/src/lib/utils.ts @@ -5,7 +5,7 @@ import gitlabLogo from "@/public/gitlab.svg"; import giteaLogo from "@/public/gitea.svg"; import gerritLogo from "@/public/gerrit.svg"; import { ServiceError } from "./serviceError"; -import { Repository } from "./types"; +import { Repository, RepositoryQuery } from "./types"; export function cn(...inputs: ClassValue[]) { 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 => { switch (codeHostType) { case "github":