mirror of
https://github.com/sourcebot-dev/sourcebot.git
synced 2025-12-12 20:35:24 +00:00
add posthog events on various user actions (#208)
* add page view event support * add posthog events * nit: remove unused import * feedback
This commit is contained in:
parent
ce52f651be
commit
de44c81cfa
41 changed files with 886 additions and 244 deletions
|
|
@ -7,6 +7,7 @@ import os from 'os';
|
|||
import { Redis } from 'ioredis';
|
||||
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js";
|
||||
import { BackendError, BackendException } from "@sourcebot/error";
|
||||
import { captureEvent } from "./posthog.js";
|
||||
|
||||
interface IConnectionManager {
|
||||
scheduleConnectionSync: (connection: Connection) => Promise<void>;
|
||||
|
|
@ -22,6 +23,10 @@ type JobPayload = {
|
|||
config: ConnectionConfig,
|
||||
};
|
||||
|
||||
type JobResult = {
|
||||
repoCount: number
|
||||
}
|
||||
|
||||
export class ConnectionManager implements IConnectionManager {
|
||||
private worker: Worker;
|
||||
private queue: Queue<JobPayload>;
|
||||
|
|
@ -217,10 +222,14 @@ export class ConnectionManager implements IConnectionManager {
|
|||
const totalUpsertDuration = performance.now() - totalUpsertStart;
|
||||
this.logger.info(`Upserted ${repoData.length} repos in ${totalUpsertDuration}ms`);
|
||||
});
|
||||
|
||||
return {
|
||||
repoCount: repoData.length,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
private async onSyncJobCompleted(job: Job<JobPayload>) {
|
||||
private async onSyncJobCompleted(job: Job<JobPayload>, result: JobResult) {
|
||||
this.logger.info(`Connection sync job ${job.id} completed`);
|
||||
const { connectionId } = job.data;
|
||||
|
||||
|
|
@ -233,14 +242,24 @@ export class ConnectionManager implements IConnectionManager {
|
|||
syncedAt: new Date()
|
||||
}
|
||||
})
|
||||
|
||||
captureEvent('backend_connection_sync_job_completed', {
|
||||
connectionId: connectionId,
|
||||
repoCount: result.repoCount,
|
||||
});
|
||||
}
|
||||
|
||||
private async onSyncJobFailed(job: Job | undefined, err: unknown) {
|
||||
this.logger.info(`Connection sync job failed with error: ${err}`);
|
||||
if (job) {
|
||||
const { connectionId } = job.data;
|
||||
|
||||
captureEvent('backend_connection_sync_job_failed', {
|
||||
connectionId: connectionId,
|
||||
error: err instanceof BackendException ? err.code : 'UNKNOWN',
|
||||
});
|
||||
|
||||
// We may have pushed some metadata during the execution of the job, so we make sure to not overwrite the metadata here
|
||||
const { connectionId } = job.data;
|
||||
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
|
||||
where: { id: connectionId },
|
||||
select: { syncStatusMetadata: true }
|
||||
|
|
|
|||
|
|
@ -5,17 +5,20 @@ export type PosthogEventMap = {
|
|||
vcs: string;
|
||||
codeHost?: string;
|
||||
},
|
||||
repo_synced: {
|
||||
vcs: string;
|
||||
codeHost?: string;
|
||||
fetchDuration_s?: number;
|
||||
cloneDuration_s?: number;
|
||||
indexDuration_s?: number;
|
||||
},
|
||||
repo_deleted: {
|
||||
vcs: string;
|
||||
codeHost?: string;
|
||||
}
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
backend_connection_sync_job_failed: {
|
||||
connectionId: number,
|
||||
error: string,
|
||||
},
|
||||
backend_connection_sync_job_completed: {
|
||||
connectionId: number,
|
||||
repoCount: number,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
}
|
||||
|
||||
export type PosthogEvent = keyof PosthogEventMap;
|
||||
|
|
@ -10,6 +10,7 @@ import { cloneRepository, fetchRepository } from "./git.js";
|
|||
import { existsSync, rmSync, readdirSync } from 'fs';
|
||||
import { indexGitRepository } from "./zoekt.js";
|
||||
import os from 'os';
|
||||
import { BackendException } from "@sourcebot/error";
|
||||
|
||||
interface IRepoManager {
|
||||
blockingPollLoop: () => void;
|
||||
|
|
@ -308,14 +309,6 @@ export class RepoManager implements IRepoManager {
|
|||
indexDuration_s = stats!.indexDuration_s;
|
||||
fetchDuration_s = stats!.fetchDuration_s;
|
||||
cloneDuration_s = stats!.cloneDuration_s;
|
||||
|
||||
captureEvent('repo_synced', {
|
||||
vcs: 'git',
|
||||
codeHost: repo.external_codeHostType,
|
||||
indexDuration_s,
|
||||
fetchDuration_s,
|
||||
cloneDuration_s,
|
||||
});
|
||||
}
|
||||
|
||||
private async onIndexJobCompleted(job: Job<JobPayload>) {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import Ajv from "ajv";
|
||||
import { auth } from "./auth";
|
||||
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription } from "@/lib/serviceError";
|
||||
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists } from "@/lib/serviceError";
|
||||
import { prisma } from "@/prisma";
|
||||
import { StatusCodes } from "http-status-codes";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
|
|
@ -14,7 +14,7 @@ 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, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { ConnectionSyncStatus, Prisma, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
|
||||
import { headers } from "next/headers"
|
||||
import { getStripe } from "@/lib/stripe"
|
||||
import { getUser } from "@/data/user";
|
||||
|
|
@ -184,6 +184,19 @@ export const createSecret = async (key: string, value: string, domain: string):
|
|||
withOrgMembership(session, domain, async ({ orgId }) => {
|
||||
try {
|
||||
const encrypted = encrypt(value);
|
||||
const existingSecret = await prisma.secret.findUnique({
|
||||
where: {
|
||||
orgId_key: {
|
||||
orgId,
|
||||
key,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (existingSecret) {
|
||||
return secretAlreadyExists();
|
||||
}
|
||||
|
||||
await prisma.secret.create({
|
||||
data: {
|
||||
orgId,
|
||||
|
|
|
|||
|
|
@ -19,7 +19,9 @@ import { Button } from "@/components/ui/button";
|
|||
import { Separator } from "@/components/ui/separator";
|
||||
import { Schema } from "ajv";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { PosthogEvent, PosthogEventMap } from "@/lib/posthogEvents";
|
||||
import { CodeHostType } from "@/lib/utils";
|
||||
export type QuickActionFn<T> = (previous: T) => T;
|
||||
export type QuickAction<T> = {
|
||||
name: string;
|
||||
|
|
@ -29,6 +31,7 @@ export type QuickAction<T> = {
|
|||
|
||||
interface ConfigEditorProps<T> {
|
||||
value: string;
|
||||
type: CodeHostType;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
onChange: (...event: any[]) => void;
|
||||
actions: QuickAction<T>[],
|
||||
|
|
@ -102,8 +105,8 @@ export const isConfigValidJson = (config: string) => {
|
|||
}
|
||||
|
||||
const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCodeMirrorRef>) => {
|
||||
const { value, onChange, actions, schema } = props;
|
||||
|
||||
const { value, type, onChange, actions, schema } = props;
|
||||
const captureEvent = useCaptureEvent();
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
useImperativeHandle(
|
||||
forwardedRef,
|
||||
|
|
@ -159,6 +162,10 @@ const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCo
|
|||
disabled={!isConfigValidJson(value)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
captureEvent('wa_config_editor_quick_action_pressed', {
|
||||
name,
|
||||
type,
|
||||
});
|
||||
if (editorRef.current?.view) {
|
||||
onQuickAction(fn, value, editorRef.current.view, {
|
||||
focusEditor: true,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import githubPatCreation from "@/public/github_pat_creation.png"
|
|||
import { CodeHostType } from "@/lib/utils";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { isDefined } from '@/lib/utils'
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface SecretComboBoxProps {
|
||||
isDisabled: boolean;
|
||||
codeHostType: CodeHostType;
|
||||
|
|
@ -47,6 +47,7 @@ export const SecretCombobox = ({
|
|||
const [searchFilter, setSearchFilter] = useState("");
|
||||
const domain = useDomain();
|
||||
const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const { data: secrets, isLoading, refetch } = useQuery({
|
||||
queryKey: ["secrets"],
|
||||
|
|
@ -154,7 +155,12 @@ export const SecretCombobox = ({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsCreateSecretDialogOpen(true)}
|
||||
onClick={() => {
|
||||
setIsCreateSecretDialogOpen(true);
|
||||
captureEvent('wa_secret_combobox_import_secret_pressed', {
|
||||
type: codeHostType,
|
||||
});
|
||||
}}
|
||||
className={cn(
|
||||
"w-full justify-start gap-1.5 p-2",
|
||||
secrets && !isServiceError(secrets) && secrets.length > 0 && "my-2"
|
||||
|
|
@ -187,10 +193,17 @@ const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHostType
|
|||
const [showValue, setShowValue] = useState(false);
|
||||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const formSchema = z.object({
|
||||
key: z.string().min(1).refine(async (key) => {
|
||||
const doesSecretExist = await checkIfSecretExists(key, domain);
|
||||
if(!isServiceError(doesSecretExist)) {
|
||||
captureEvent('wa_secret_combobox_import_secret_fail', {
|
||||
type: codeHostType,
|
||||
error: "A secret with this key already exists.",
|
||||
});
|
||||
}
|
||||
return isServiceError(doesSecretExist) || !doesSecretExist;
|
||||
}, "A secret with this key already exists."),
|
||||
value: z.string().min(1),
|
||||
|
|
@ -211,15 +224,22 @@ const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHostType
|
|||
toast({
|
||||
description: `❌ Failed to create secret`
|
||||
});
|
||||
captureEvent('wa_secret_combobox_import_secret_fail', {
|
||||
type: codeHostType,
|
||||
error: response.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Secret created successfully!`
|
||||
});
|
||||
captureEvent('wa_secret_combobox_import_secret_success', {
|
||||
type: codeHostType,
|
||||
});
|
||||
form.reset();
|
||||
onOpenChange(false);
|
||||
onSecretCreated(data.key);
|
||||
}
|
||||
}, [domain, toast, onOpenChange, onSecretCreated, form]);
|
||||
}, [domain, toast, onOpenChange, onSecretCreated, form, codeHostType, captureEvent]);
|
||||
|
||||
const codeHostSpecificStep = useMemo(() => {
|
||||
switch (codeHostType) {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { Loader2 } from "lucide-react";
|
|||
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
|
||||
import { SecretCombobox } from "./secretCombobox";
|
||||
import strings from "@/lib/strings";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface SharedConnectionCreationFormProps<T> {
|
||||
type: CodeHostType;
|
||||
|
|
@ -51,7 +52,7 @@ export default function SharedConnectionCreationForm<T>({
|
|||
const { toast } = useToast();
|
||||
const domain = useDomain();
|
||||
const editorRef = useRef<ReactCodeMirrorRef>(null);
|
||||
|
||||
const captureEvent = useCaptureEvent();
|
||||
const formSchema = useMemo(() => {
|
||||
return z.object({
|
||||
name: z.string().min(1),
|
||||
|
|
@ -64,7 +65,7 @@ export default function SharedConnectionCreationForm<T>({
|
|||
return checkIfSecretExists(secretKey, domain);
|
||||
}, { message: "Secret not found" }),
|
||||
});
|
||||
}, [schema]);
|
||||
}, [schema, domain]);
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
|
|
@ -78,13 +79,20 @@ export default function SharedConnectionCreationForm<T>({
|
|||
toast({
|
||||
description: `❌ Failed to create connection. Reason: ${response.message}`
|
||||
});
|
||||
captureEvent('wa_create_connection_fail', {
|
||||
type: type,
|
||||
error: response.message,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Connection created successfully.`
|
||||
});
|
||||
captureEvent('wa_create_connection_success', {
|
||||
type: type,
|
||||
});
|
||||
onCreated?.(response.id);
|
||||
}
|
||||
}, [domain, toast, type, onCreated]);
|
||||
}, [domain, toast, type, onCreated, captureEvent]);
|
||||
|
||||
const onConfigChange = useCallback((value: string) => {
|
||||
form.setValue("config", value);
|
||||
|
|
@ -168,6 +176,9 @@ export default function SharedConnectionCreationForm<T>({
|
|||
}
|
||||
}
|
||||
},
|
||||
captureEvent,
|
||||
"set-secret",
|
||||
type,
|
||||
form.getValues("config"),
|
||||
view,
|
||||
{
|
||||
|
|
@ -193,6 +204,7 @@ export default function SharedConnectionCreationForm<T>({
|
|||
<FormControl>
|
||||
<ConfigEditor<T>
|
||||
ref={editorRef}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={onConfigChange}
|
||||
actions={quickActions ?? []}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { useDomain } from "@/hooks/useDomain";
|
|||
import { getConnectionFailedRepos, getConnections } from "@/actions";
|
||||
import { useState, useEffect } from "react";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
enum ConnectionErrorType {
|
||||
SYNC_FAILED = "SYNC_FAILED",
|
||||
|
|
@ -23,6 +24,7 @@ interface Error {
|
|||
export const ErrorNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const [errors, setErrors] = useState<Error[]>([]);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchErrors = async () => {
|
||||
|
|
@ -39,15 +41,25 @@ export const ErrorNavIndicator = () => {
|
|||
}
|
||||
|
||||
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
|
||||
if (!isServiceError(failedRepos) && failedRepos.length > 0) {
|
||||
errors.push({
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name,
|
||||
numRepos: failedRepos.length,
|
||||
errorType: ConnectionErrorType.REPO_INDEXING_FAILED
|
||||
if (!isServiceError(failedRepos)) {
|
||||
if (failedRepos.length > 0) {
|
||||
errors.push({
|
||||
connectionId: connection.id,
|
||||
connectionName: connection.name,
|
||||
numRepos: failedRepos.length,
|
||||
errorType: ConnectionErrorType.REPO_INDEXING_FAILED
|
||||
});
|
||||
}
|
||||
} else {
|
||||
captureEvent('wa_error_nav_job_fetch_fail', {
|
||||
error: failedRepos.errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
captureEvent('wa_error_nav_connection_fetch_fail', {
|
||||
error: connections.errorCode,
|
||||
});
|
||||
}
|
||||
setErrors(prevErrors => {
|
||||
// Only update if the errors have actually changed
|
||||
|
|
@ -62,14 +74,14 @@ export const ErrorNavIndicator = () => {
|
|||
};
|
||||
|
||||
fetchErrors();
|
||||
}, [domain]);
|
||||
}, [domain, captureEvent]);
|
||||
|
||||
if (errors.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Link href={`/${domain}/connections`}>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
|
||||
<CircleXIcon className="h-4 w-4" />
|
||||
{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0) > 0 && (
|
||||
|
|
@ -93,7 +105,7 @@ export const ErrorNavIndicator = () => {
|
|||
.filter(e => e.errorType === 'SYNC_FAILED')
|
||||
.slice(0, 10)
|
||||
.map(error => (
|
||||
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`}>
|
||||
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
|
||||
rounded-md text-sm text-red-700 dark:text-red-300
|
||||
border border-red-200/50 dark:border-red-800/50
|
||||
|
|
@ -125,7 +137,7 @@ export const ErrorNavIndicator = () => {
|
|||
.filter(e => e.errorType === 'REPO_INDEXING_FAILED')
|
||||
.slice(0, 10)
|
||||
.map(error => (
|
||||
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`}>
|
||||
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
|
||||
<div className="flex items-center justify-between px-3 py-2
|
||||
bg-red-50 dark:bg-red-900/20 rounded-md
|
||||
border border-red-200/50 dark:border-red-800/50
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { ErrorNavIndicator } from "./errorNavIndicator";
|
|||
import { WarningNavIndicator } from "./warningNavIndicator";
|
||||
import { ProgressNavIndicator } from "./progressNavIndicator";
|
||||
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
|
||||
import { TrialNavIndicator } from "./trialNavIndicator";
|
||||
|
||||
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
|
||||
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
|
||||
|
|
@ -89,16 +90,7 @@ export const NavigationMenu = async ({
|
|||
<ProgressNavIndicator />
|
||||
<WarningNavIndicator />
|
||||
<ErrorNavIndicator />
|
||||
{!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>
|
||||
<span>
|
||||
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in trial
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
<TrialNavIndicator subscription={subscription} />
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
|
|||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { getConnectionInProgressRepos, getConnections } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface InProgress {
|
||||
connectionId: number;
|
||||
repoId: number;
|
||||
|
|
@ -18,6 +18,7 @@ interface InProgress {
|
|||
export const ProgressNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const [inProgressJobs, setInProgressJobs] = useState<InProgress[]>([]);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchInProgressJobs = async () => {
|
||||
|
|
@ -31,6 +32,10 @@ export const ProgressNavIndicator = () => {
|
|||
connectionId: connection.id,
|
||||
...repo
|
||||
})));
|
||||
} else {
|
||||
captureEvent('wa_progress_nav_job_fetch_fail', {
|
||||
error: inProgressRepos.errorCode,
|
||||
});
|
||||
}
|
||||
}
|
||||
setInProgressJobs(prevJobs => {
|
||||
|
|
@ -42,20 +47,24 @@ export const ProgressNavIndicator = () => {
|
|||
);
|
||||
return jobsChanged ? allInProgressRepos : prevJobs;
|
||||
});
|
||||
} else {
|
||||
captureEvent('wa_progress_nav_connection_fetch_fail', {
|
||||
error: connections.errorCode,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
fetchInProgressJobs();
|
||||
}, [domain]);
|
||||
}, [domain, captureEvent]);
|
||||
|
||||
if (inProgressJobs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/${domain}/connections`}>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_progress_nav_pressed', {})}>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
|
||||
<Loader2Icon className="h-4 w-4 animate-spin" />
|
||||
<span>{inProgressJobs.length}</span>
|
||||
|
|
@ -72,7 +81,7 @@ export const ProgressNavIndicator = () => {
|
|||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
{inProgressJobs.slice(0, 10).map(item => (
|
||||
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`}>
|
||||
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`} onClick={() => captureEvent('wa_progress_nav_job_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
|
||||
rounded-md text-sm text-green-700 dark:text-green-300
|
||||
border border-green-200/50 dark:border-green-800/50
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { ServiceError } from "@/lib/serviceError";
|
||||
|
||||
interface Props {
|
||||
subscription: {
|
||||
status: string;
|
||||
nextBillingDate: number;
|
||||
} | null | ServiceError;
|
||||
}
|
||||
|
||||
export const TrialNavIndicator = ({ subscription }: Props) => {
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
if (isServiceError(subscription)) {
|
||||
captureEvent('wa_trial_nav_subscription_fetch_fail', {
|
||||
error: subscription.errorCode,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!subscription || subscription.status !== "trialing") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/${domain}/upgrade`} onClick={() => captureEvent('wa_trial_nav_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
|
||||
<span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>
|
||||
<span>
|
||||
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in trial
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -9,7 +9,7 @@ import { useState } from "react";
|
|||
import { useEffect } from "react";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface Warning {
|
||||
connectionId?: number;
|
||||
connectionName?: string;
|
||||
|
|
@ -18,6 +18,7 @@ interface Warning {
|
|||
export const WarningNavIndicator = () => {
|
||||
const domain = useDomain();
|
||||
const [warnings, setWarnings] = useState<Warning[]>([]);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchWarnings = async () => {
|
||||
|
|
@ -33,7 +34,12 @@ export const WarningNavIndicator = () => {
|
|||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
captureEvent('wa_warning_nav_connection_fetch_fail', {
|
||||
error: connections.errorCode,
|
||||
});
|
||||
}
|
||||
|
||||
setWarnings(prevWarnings => {
|
||||
// Only update if the warnings have actually changed
|
||||
const warningsChanged = prevWarnings.length !== warnings.length ||
|
||||
|
|
@ -46,16 +52,16 @@ export const WarningNavIndicator = () => {
|
|||
};
|
||||
|
||||
fetchWarnings();
|
||||
}, [domain]);
|
||||
}, [domain, captureEvent]);
|
||||
|
||||
if (warnings.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href={`/${domain}/connections`}>
|
||||
<HoverCard>
|
||||
<HoverCardTrigger asChild>
|
||||
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_warning_nav_pressed', {})}>
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<span>{warnings.length}</span>
|
||||
|
|
@ -72,7 +78,7 @@ export const WarningNavIndicator = () => {
|
|||
</p>
|
||||
<div className="flex flex-col gap-2 pl-4">
|
||||
{warnings.slice(0, 10).map(warning => (
|
||||
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`}>
|
||||
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
|
||||
rounded-md text-sm text-yellow-700 dark:text-yellow-300
|
||||
border border-yellow-200/50 dark:border-yellow-800/50
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { isServiceError } from "@/lib/utils";
|
|||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface DeleteConnectionSettingProps {
|
||||
connectionId: number;
|
||||
|
|
@ -32,6 +33,7 @@ export const DeleteConnectionSetting = ({
|
|||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setIsDialogOpen(false);
|
||||
|
|
@ -42,10 +44,14 @@ export const DeleteConnectionSetting = ({
|
|||
toast({
|
||||
description: `❌ Failed to delete connection. Reason: ${response.message}`
|
||||
});
|
||||
captureEvent('wa_connection_delete_fail', {
|
||||
error: response.errorCode,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Connection deleted successfully.`
|
||||
});
|
||||
captureEvent('wa_connection_delete_success', {});
|
||||
router.replace(`/${domain}/connections`);
|
||||
router.refresh();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react"
|
|||
import { Prisma } from "@sourcebot/db"
|
||||
import { RetrySyncButton } from "./retrySyncButton"
|
||||
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema"
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface NotFoundWarningProps {
|
||||
syncStatusMetadata: Prisma.JsonValue
|
||||
onSecretsClick: () => void
|
||||
|
|
@ -12,6 +12,8 @@ interface NotFoundWarningProps {
|
|||
}
|
||||
|
||||
export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connectionId, domain, connectionType }: NotFoundWarningProps) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
|
||||
if (!parseResult.success || !parseResult.data.notFound) {
|
||||
return null;
|
||||
|
|
@ -21,6 +23,8 @@ export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connection
|
|||
|
||||
if (notFound.users.length === 0 && notFound.orgs.length === 0 && notFound.repos.length === 0) {
|
||||
return null;
|
||||
} else {
|
||||
captureEvent('wa_connection_not_found_warning_displayed', {});
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ReloadIcon } from "@radix-ui/react-icons"
|
|||
import { toast } from "@/components/hooks/use-toast";
|
||||
import { flagRepoForIndex } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface RetryRepoIndexButtonProps {
|
||||
repoId: number;
|
||||
|
|
@ -12,6 +13,8 @@ interface RetryRepoIndexButtonProps {
|
|||
}
|
||||
|
||||
export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -23,10 +26,14 @@ export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonPro
|
|||
toast({
|
||||
description: `❌ Failed to flag repository for indexing.`,
|
||||
});
|
||||
captureEvent('wa_repo_retry_index_fail', {
|
||||
error: result.errorCode,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "✅ Repository flagged for indexing.",
|
||||
});
|
||||
captureEvent('wa_repo_retry_index_success', {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ReloadIcon } from "@radix-ui/react-icons"
|
|||
import { toast } from "@/components/hooks/use-toast";
|
||||
import { flagRepoForIndex, getConnectionFailedRepos } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface RetryAllFailedReposButtonProps {
|
||||
connectionId: number;
|
||||
|
|
@ -12,17 +13,23 @@ interface RetryAllFailedReposButtonProps {
|
|||
}
|
||||
|
||||
export const RetryAllFailedReposButton = ({ connectionId, domain }: RetryAllFailedReposButtonProps) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-2"
|
||||
onClick={async () => {
|
||||
captureEvent('wa_connection_retry_all_failed_repos_pressed', {});
|
||||
const failedRepos = await getConnectionFailedRepos(connectionId, domain);
|
||||
if (isServiceError(failedRepos)) {
|
||||
toast({
|
||||
description: `❌ Failed to get failed repositories.`,
|
||||
});
|
||||
captureEvent('wa_connection_retry_all_failed_repos_fetch_fail', {
|
||||
error: failedRepos.errorCode,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -42,14 +49,22 @@ export const RetryAllFailedReposButton = ({ connectionId, domain }: RetryAllFail
|
|||
toast({
|
||||
description: `⚠️ ${successCount} repositories flagged for indexing, ${failureCount} failed.`,
|
||||
});
|
||||
captureEvent('wa_connection_retry_all_failed_repos_fail', {
|
||||
successCount,
|
||||
failureCount,
|
||||
});
|
||||
} else if (successCount > 0) {
|
||||
toast({
|
||||
description: `✅ ${successCount} repositories flagged for indexing.`,
|
||||
});
|
||||
captureEvent('wa_connection_retry_all_failed_repos_success', {
|
||||
successCount,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "ℹ️ No failed repositories to retry.",
|
||||
});
|
||||
captureEvent('wa_connection_retry_all_failed_no_repos', {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { ReloadIcon } from "@radix-ui/react-icons"
|
|||
import { toast } from "@/components/hooks/use-toast";
|
||||
import { flagConnectionForSync } from "@/actions";
|
||||
import { isServiceError } from "@/lib/utils";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface RetrySyncButtonProps {
|
||||
connectionId: number;
|
||||
|
|
@ -12,6 +13,8 @@ interface RetrySyncButtonProps {
|
|||
}
|
||||
|
||||
export const RetrySyncButton = ({ connectionId, domain }: RetrySyncButtonProps) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
|
|
@ -23,10 +26,14 @@ export const RetrySyncButton = ({ connectionId, domain }: RetrySyncButtonProps)
|
|||
toast({
|
||||
description: `❌ Failed to flag connection for sync.`,
|
||||
});
|
||||
captureEvent('wa_connection_retry_sync_fail', {
|
||||
error: result.errorCode,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: "✅ Connection flagged for sync.",
|
||||
});
|
||||
captureEvent('wa_connection_retry_sync_success', {});
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import { DisplayConnectionError } from "./components/connectionError"
|
|||
import { NotFoundWarning } from "./components/notFoundWarning"
|
||||
import { RetrySyncButton } from "./components/retrySyncButton"
|
||||
import { RetryAllFailedReposButton } from "./components/retryAllFailedReposButton"
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
export default function ConnectionManagementPage() {
|
||||
const params = useParams()
|
||||
|
|
@ -38,8 +39,10 @@ export default function ConnectionManagementPage() {
|
|||
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const handleSecretsNavigation = () => {
|
||||
captureEvent('wa_connection_secrets_navigation_pressed', {});
|
||||
router.push(`/${params.domain}/secrets`)
|
||||
}
|
||||
|
||||
|
|
@ -174,7 +177,7 @@ export default function ConnectionManagementPage() {
|
|||
<div className="flex items-center gap-2 mt-2">
|
||||
{connection.syncStatus === "FAILED" ? (
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_connection_failed_status_hover', {})}>
|
||||
<div className="flex items-center">
|
||||
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-600/20 cursor-help hover:text-red-600 hover:bg-red-100 transition-colors duration-200">
|
||||
{connection.syncStatus}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { Button } from "@/components/ui/button";
|
||||
import { getDisplayTime } from "@/lib/utils";
|
||||
import { useMemo } from "react";
|
||||
import { ConnectionIcon } from "../connectionIcon";
|
||||
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
|
||||
import { StatusIcon } from "../statusIcon";
|
||||
import { AlertTriangle, CircleX} from "lucide-react";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { ConnectionListItemErrorIndicator } from "./connectionListItemErrorIndicator";
|
||||
import { ConnectionListItemWarningIndicator } from "./connectionListItemWarningIndicator";
|
||||
import { ConnectionListItemManageButton } from "./connectionListItemManageButton";
|
||||
|
||||
const convertSyncStatus = (status: ConnectionSyncStatus) => {
|
||||
switch (status) {
|
||||
|
|
@ -83,92 +83,13 @@ export const ConnectionListItem = ({
|
|||
<p className="font-medium">{name}</p>
|
||||
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
|
||||
</div>
|
||||
{failedRepos && failedRepos.length > 0 && (
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<CircleX
|
||||
className="h-5 w-5 text-red-700 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
|
||||
onClick={() => window.location.href = `connections/${id}`}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-red-200 dark:border-red-800">
|
||||
<CircleX className="h-4 w-4 text-red-700 dark:text-red-400" />
|
||||
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400">Failed to Index Repositories</h3>
|
||||
</div>
|
||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 space-y-3">
|
||||
<p>
|
||||
{failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm bg-red-50 dark:bg-red-900/20 rounded-md p-3 border border-red-200/50 dark:border-red-800/50">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{failedRepos.slice(0, 10).map(repo => (
|
||||
<span key={repo.repoId} className="text-red-700 dark:text-red-300">{repo.repoName}</span>
|
||||
))}
|
||||
{failedRepos.length > 10 && (
|
||||
<span className="text-red-600/75 dark:text-red-400/75 text-xs pt-1">
|
||||
And {failedRepos.length - 10} more...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs">
|
||||
Navigate to the connection for more details and to retry indexing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
{(notFoundData && displayNotFoundWarning) && (
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<AlertTriangle
|
||||
className="h-5 w-5 text-yellow-700 dark:text-yellow-400 cursor-help hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
|
||||
onClick={() => window.location.href = `connections/${id}`}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-800">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
|
||||
<h3 className="text-sm font-semibold text-yellow-700 dark:text-yellow-400">Unable to fetch all references</h3>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90">
|
||||
Some requested references couldn't be found. Verify the details below and ensure your connection is using a {" "}
|
||||
<button
|
||||
onClick={() => window.location.href = `secrets`}
|
||||
className="font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
|
||||
>
|
||||
valid access token
|
||||
</button>{" "}
|
||||
that has access to any private references.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm bg-yellow-50 dark:bg-yellow-900/20 rounded-md p-3 border border-yellow-200/50 dark:border-yellow-800/50">
|
||||
{notFoundData.users.length > 0 && (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">Users:</span>
|
||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.users.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{notFoundData.orgs.length > 0 && (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Groups" : "Organizations"}:</span>
|
||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.orgs.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{notFoundData.repos.length > 0 && (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Projects" : "Repositories"}:</span>
|
||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.repos.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
)}
|
||||
<ConnectionListItemErrorIndicator failedRepos={failedRepos} connectionId={id} />
|
||||
<ConnectionListItemWarningIndicator
|
||||
notFoundData={notFoundData}
|
||||
connectionId={id}
|
||||
type={type}
|
||||
displayWarning={displayNotFoundWarning}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-row items-center">
|
||||
<StatusIcon
|
||||
|
|
@ -186,14 +107,7 @@ export const ConnectionListItem = ({
|
|||
)
|
||||
}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size={"sm"}
|
||||
className="ml-4"
|
||||
onClick={() => window.location.href = `connections/${id}`}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
<ConnectionListItemManageButton id={id} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
'use client'
|
||||
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { CircleX } from "lucide-react";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface ConnectionListItemErrorIndicatorProps {
|
||||
failedRepos: { repoId: number; repoName: string; }[] | undefined;
|
||||
connectionId: string;
|
||||
}
|
||||
|
||||
export const ConnectionListItemErrorIndicator = ({
|
||||
failedRepos,
|
||||
connectionId
|
||||
}: ConnectionListItemErrorIndicatorProps) => {
|
||||
const captureEvent = useCaptureEvent()
|
||||
|
||||
if (!failedRepos || failedRepos.length === 0) return null;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<CircleX
|
||||
className="h-5 w-5 text-red-700 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
|
||||
onClick={() => {
|
||||
captureEvent('wa_connection_list_item_error_pressed', {})
|
||||
window.location.href = `connections/${connectionId}`
|
||||
}}
|
||||
onMouseEnter={() => captureEvent('wa_connection_list_item_error_hover', {})}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-red-200 dark:border-red-800">
|
||||
<CircleX className="h-4 w-4 text-red-700 dark:text-red-400" />
|
||||
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400">Failed to Index Repositories</h3>
|
||||
</div>
|
||||
<div className="text-sm text-red-600/90 dark:text-red-300/90 space-y-3">
|
||||
<p>
|
||||
{failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load.
|
||||
</p>
|
||||
<div className="space-y-2 text-sm bg-red-50 dark:bg-red-900/20 rounded-md p-3 border border-red-200/50 dark:border-red-800/50">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{failedRepos.slice(0, 10).map(repo => (
|
||||
<span key={repo.repoId} className="text-red-700 dark:text-red-300">{repo.repoName}</span>
|
||||
))}
|
||||
{failedRepos.length > 10 && (
|
||||
<span className="text-red-600/75 dark:text-red-400/75 text-xs pt-1">
|
||||
And {failedRepos.length - 10} more...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs">
|
||||
Navigate to the connection for more details and to retry indexing.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
interface ConnectionListItemManageButtonProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const ConnectionListItemManageButton = ({
|
||||
id
|
||||
}: ConnectionListItemManageButtonProps) => {
|
||||
const captureEvent = useCaptureEvent()
|
||||
const router = useRouter();
|
||||
const domain = useDomain();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={"sm"}
|
||||
className="ml-4"
|
||||
onClick={() => {
|
||||
captureEvent('wa_connection_list_item_manage_pressed', {})
|
||||
router.push(`/${domain}/connections/${id}`)
|
||||
}}
|
||||
>
|
||||
Manage
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
'use client'
|
||||
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { NotFoundData } from "@/lib/syncStatusMetadataSchema";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
|
||||
interface ConnectionListItemWarningIndicatorProps {
|
||||
notFoundData: NotFoundData | null;
|
||||
connectionId: string;
|
||||
type: string;
|
||||
displayWarning: boolean;
|
||||
}
|
||||
|
||||
export const ConnectionListItemWarningIndicator = ({
|
||||
notFoundData,
|
||||
connectionId,
|
||||
type,
|
||||
displayWarning
|
||||
}: ConnectionListItemWarningIndicatorProps) => {
|
||||
const captureEvent = useCaptureEvent()
|
||||
|
||||
if (!notFoundData || !displayWarning) return null;
|
||||
|
||||
return (
|
||||
<HoverCard openDelay={50}>
|
||||
<HoverCardTrigger asChild>
|
||||
<AlertTriangle
|
||||
className="h-5 w-5 text-yellow-700 dark:text-yellow-400 cursor-help hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
|
||||
onClick={() => {
|
||||
captureEvent('wa_connection_list_item_warning_pressed', {})
|
||||
window.location.href = `connections/${connectionId}`
|
||||
}}
|
||||
onMouseEnter={() => captureEvent('wa_connection_list_item_warning_hover', {})}
|
||||
/>
|
||||
</HoverCardTrigger>
|
||||
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
|
||||
<div className="flex flex-col space-y-3">
|
||||
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-800">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
|
||||
<h3 className="text-sm font-semibold text-yellow-700 dark:text-yellow-400">Unable to fetch all references</h3>
|
||||
</div>
|
||||
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90">
|
||||
Some requested references couldn't be found. Verify the details below and ensure your connection is using a {" "}
|
||||
<button
|
||||
onClick={() => window.location.href = `secrets`}
|
||||
className="font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
|
||||
>
|
||||
valid access token
|
||||
</button>{" "}
|
||||
that has access to any private references.
|
||||
</p>
|
||||
<ul className="space-y-2 text-sm bg-yellow-50 dark:bg-yellow-900/20 rounded-md p-3 border border-yellow-200/50 dark:border-yellow-800/50">
|
||||
{notFoundData.users.length > 0 && (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">Users:</span>
|
||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.users.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{notFoundData.orgs.length > 0 && (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Groups" : "Organizations"}:</span>
|
||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.orgs.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
{notFoundData.repos.length > 0 && (
|
||||
<li className="flex items-center gap-2">
|
||||
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Projects" : "Repositories"}:</span>
|
||||
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.repos.join(', ')}</span>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
};
|
||||
|
|
@ -12,6 +12,7 @@ import { Check, Loader2 } from "lucide-react";
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TEAM_FEATURES } from "@/lib/constants";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
export const Checkout = () => {
|
||||
const domain = useDomain();
|
||||
|
|
@ -20,6 +21,7 @@ export const Checkout = () => {
|
|||
const errorMessage = useNonEmptyQueryParam('errorMessage');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const router = useRouter();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
if (errorCode === ErrorCode.STRIPE_CHECKOUT_ERROR && errorMessage) {
|
||||
|
|
@ -27,8 +29,11 @@ export const Checkout = () => {
|
|||
description: `⚠️ Stripe checkout failed with error: ${errorMessage}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
captureEvent('wa_onboard_checkout_fail', {
|
||||
error: errorMessage,
|
||||
});
|
||||
}
|
||||
}, [errorCode, errorMessage, toast]);
|
||||
}, [errorCode, errorMessage, toast, captureEvent]);
|
||||
|
||||
const onCheckout = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
|
|
@ -39,14 +44,18 @@ export const Checkout = () => {
|
|||
description: `❌ Stripe checkout failed with error: ${response.message}`,
|
||||
variant: "destructive",
|
||||
})
|
||||
captureEvent('wa_onboard_checkout_fail', {
|
||||
error: response.errorCode,
|
||||
});
|
||||
} else {
|
||||
router.push(response.url);
|
||||
captureEvent('wa_onboard_checkout_success', {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [domain, router, toast]);
|
||||
}, [domain, router, toast, captureEvent]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center max-w-md my-auto">
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
|
|||
import { useCallback } from "react";
|
||||
import { OnboardingSteps } from "@/lib/constants";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface ConnectCodeHostProps {
|
||||
nextStep: OnboardingSteps;
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ interface ConnectCodeHostProps {
|
|||
export const ConnectCodeHost = ({ nextStep }: ConnectCodeHostProps) => {
|
||||
const [selectedCodeHost, setSelectedCodeHost] = useState<CodeHostType | null>(null);
|
||||
const router = useRouter();
|
||||
const captureEvent = useCaptureEvent();
|
||||
const onCreated = useCallback(() => {
|
||||
router.push(`?step=${nextStep}`);
|
||||
}, [nextStep, router]);
|
||||
|
|
@ -101,11 +102,17 @@ const CodeHostButton = ({
|
|||
logo,
|
||||
onClick,
|
||||
}: CodeHostButtonProps) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
return (
|
||||
<Button
|
||||
className="flex flex-col items-center justify-center p-4 w-24 h-24 cursor-pointer gap-2"
|
||||
variant="outline"
|
||||
onClick={onClick}
|
||||
onClick={() => {
|
||||
captureEvent('wa_connect_code_host_button_pressed', {
|
||||
name,
|
||||
})
|
||||
onClick();
|
||||
}}
|
||||
>
|
||||
<Image src={logo.src} alt={name} className={cn("w-8 h-8", logo.className)} />
|
||||
<p className="text-sm font-medium">{name}</p>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { useDomain } from "@/hooks/useDomain";
|
|||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { OnboardingSteps } from "@/lib/constants";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface InviteTeamProps {
|
||||
nextStep: OnboardingSteps;
|
||||
}
|
||||
|
|
@ -25,6 +25,7 @@ export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
|
|||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
|
||||
resolver: zodResolver(inviteMemberFormSchema),
|
||||
|
|
@ -48,17 +49,27 @@ export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
|
|||
toast({
|
||||
description: `❌ Failed to invite members. Reason: ${response.message}`
|
||||
});
|
||||
captureEvent('wa_onboard_invite_team_invite_fail', {
|
||||
error: response.errorCode,
|
||||
num_emails: data.emails.length,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Successfully invited ${data.emails.length} members`
|
||||
});
|
||||
captureEvent('wa_onboard_invite_team_invite_success', {
|
||||
num_emails: data.emails.length,
|
||||
});
|
||||
onComplete();
|
||||
}
|
||||
}, [domain, toast, onComplete]);
|
||||
}, [domain, toast, onComplete, captureEvent]);
|
||||
|
||||
const onSkip = useCallback(() => {
|
||||
captureEvent('wa_onboard_invite_team_skip', {
|
||||
num_emails: form.getValues().emails.length,
|
||||
});
|
||||
onComplete();
|
||||
}, [onComplete]);
|
||||
}, [onComplete, form, captureEvent]);
|
||||
|
||||
return (
|
||||
<Card className="p-12 w-[500px]">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
'use client';
|
||||
|
||||
import Link from "next/link";
|
||||
import { OnboardingSteps } from "@/lib/constants";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface SkipOnboardingButtonProps {
|
||||
currentStep: OnboardingSteps;
|
||||
lastRequiredStep: OnboardingSteps;
|
||||
}
|
||||
|
||||
export const SkipOnboardingButton = ({ currentStep, lastRequiredStep }: SkipOnboardingButtonProps) => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const handleClick = () => {
|
||||
captureEvent('wa_onboard_skip_onboarding', {
|
||||
step: currentStep
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Link
|
||||
className="text-sm text-muted-foreground underline cursor-pointer mt-12"
|
||||
href={`?step=${lastRequiredStep}`}
|
||||
onClick={handleClick}
|
||||
>
|
||||
Skip onboarding
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
|
@ -4,11 +4,10 @@ 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";
|
||||
|
||||
import { SkipOnboardingButton } from "./components/skipOnboardingButton";
|
||||
interface OnboardProps {
|
||||
params: {
|
||||
domain: string
|
||||
|
|
@ -21,6 +20,7 @@ interface OnboardProps {
|
|||
|
||||
export default async function Onboard({ params, searchParams }: OnboardProps) {
|
||||
const org = await getOrgFromDomain(params.domain);
|
||||
|
||||
if (!org) {
|
||||
notFound();
|
||||
}
|
||||
|
|
@ -54,12 +54,10 @@ export default async function Onboard({ params, searchParams }: OnboardProps) {
|
|||
<ConnectCodeHost
|
||||
nextStep={OnboardingSteps.InviteTeam}
|
||||
/>
|
||||
<Link
|
||||
className="text-sm text-muted-foreground underline cursor-pointer mt-12"
|
||||
href={`?step=${lastRequiredStep}`}
|
||||
>
|
||||
Skip onboarding
|
||||
</Link>
|
||||
<SkipOnboardingButton
|
||||
currentStep={step as OnboardingSteps}
|
||||
lastRequiredStep={lastRequiredStep}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{step === OnboardingSteps.InviteTeam && (
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@ import { isServiceError } from "@/lib/utils";
|
|||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { deleteSecret } from "../../../actions"
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
import { PosthogEvent } from "@/lib/posthogEvents";
|
||||
import { ErrorCode } from "@/lib/errorCodes";
|
||||
const formSchema = z.object({
|
||||
key: z.string().min(2).max(40),
|
||||
value: z.string().min(2),
|
||||
|
|
@ -29,6 +31,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
|||
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
|
||||
const { toast } = useToast();
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSecretKeys = async () => {
|
||||
|
|
@ -54,19 +57,35 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
|||
const handleCreateSecret = async (values: { key: string, value: string }) => {
|
||||
const res = await createSecret(values.key, values.value, domain);
|
||||
if (isServiceError(res)) {
|
||||
toast({
|
||||
description: `❌ Failed to create secret`
|
||||
if (res.errorCode === ErrorCode.SECRET_ALREADY_EXISTS) {
|
||||
toast({
|
||||
description: `❌ Secret with key ${values.key} already exists`
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
description: `❌ Failed to create secret`
|
||||
});
|
||||
}
|
||||
captureEvent('wa_secret_created_fail', {
|
||||
key: values.key,
|
||||
error: res.errorCode,
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Secret created successfully!`
|
||||
});
|
||||
captureEvent('wa_secret_created_success', {
|
||||
key: values.key,
|
||||
});
|
||||
}
|
||||
|
||||
const keys = await getSecrets(domain);
|
||||
if (isServiceError(keys)) {
|
||||
console.error("Failed to fetch secrets");
|
||||
captureEvent('wa_secret_fetch_fail', {
|
||||
error: keys.errorCode,
|
||||
});
|
||||
} else {
|
||||
setSecrets(keys);
|
||||
|
||||
|
|
@ -82,11 +101,18 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
|
|||
toast({
|
||||
description: `❌ Failed to delete secret`
|
||||
});
|
||||
captureEvent('wa_secret_deleted_fail', {
|
||||
key: key,
|
||||
error: res.errorCode,
|
||||
});
|
||||
return;
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Secret deleted successfully!`
|
||||
});
|
||||
captureEvent('wa_secret_deleted_success', {
|
||||
key: key,
|
||||
});
|
||||
}
|
||||
|
||||
const keys = await getSecrets(domain);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { useForm } from "react-hook-form"
|
|||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
const formSchema = z.object({
|
||||
email: z.string().email("Please enter a valid email address"),
|
||||
})
|
||||
|
|
@ -28,6 +28,7 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
|
|||
const [billingEmail, setBillingEmail] = useState<string>("")
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const { toast } = useToast()
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
|
|
@ -41,10 +42,14 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
|
|||
const email = await getSubscriptionBillingEmail(domain)
|
||||
if (!isServiceError(email)) {
|
||||
setBillingEmail(email)
|
||||
} else {
|
||||
captureEvent('wa_billing_email_fetch_fail', {
|
||||
error: email.errorCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
fetchBillingEmail()
|
||||
}, [domain])
|
||||
}, [domain, captureEvent])
|
||||
|
||||
const onSubmit = async (values: z.infer<typeof formSchema>) => {
|
||||
setIsLoading(true)
|
||||
|
|
@ -56,10 +61,14 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
|
|||
toast({
|
||||
description: "✅ Billing email updated successfully!",
|
||||
})
|
||||
captureEvent('wa_billing_email_updated_success', {})
|
||||
} else {
|
||||
toast({
|
||||
description: "❌ Failed to update billing email. Please try again.",
|
||||
})
|
||||
captureEvent('wa_billing_email_updated_fail', {
|
||||
error: result.message,
|
||||
})
|
||||
}
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,23 +7,28 @@ import { Button } from "@/components/ui/button"
|
|||
import { getCustomerPortalSessionLink } from "@/actions"
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
import { OrgRole } from "@sourcebot/db";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
const domain = useDomain();
|
||||
|
||||
const captureEvent = useCaptureEvent();
|
||||
const redirectToCustomerPortal = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const session = await getCustomerPortalSessionLink(domain)
|
||||
if (isServiceError(session)) {
|
||||
console.log("Failed to create portal session: ", session)
|
||||
captureEvent('wa_manage_subscription_button_create_portal_session_fail', {
|
||||
error: session.errorCode,
|
||||
})
|
||||
} else {
|
||||
router.push(session)
|
||||
captureEvent('wa_manage_subscription_button_create_portal_session_success', {})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating portal session:", error)
|
||||
captureEvent('wa_manage_subscription_button_create_portal_session_fail', {
|
||||
error: "Unknown error",
|
||||
})
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { useDomain } from "@/hooks/useDomain";
|
|||
import { isServiceError } from "@/lib/utils";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
export const inviteMemberFormSchema = z.object({
|
||||
emails: z.array(z.object({
|
||||
email: z.string().email()
|
||||
|
|
@ -37,6 +37,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
|||
const domain = useDomain();
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
|
||||
resolver: zodResolver(inviteMemberFormSchema),
|
||||
|
|
@ -58,6 +59,10 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
|||
toast({
|
||||
description: `❌ Failed to invite members. Reason: ${res.message}`
|
||||
});
|
||||
captureEvent('wa_invite_member_card_invite_fail', {
|
||||
error: res.errorCode,
|
||||
num_emails: data.emails.length,
|
||||
});
|
||||
} else {
|
||||
form.reset();
|
||||
router.push(`?tab=invites`);
|
||||
|
|
@ -65,12 +70,15 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
|||
toast({
|
||||
description: `✅ Successfully invited ${data.emails.length} members`
|
||||
});
|
||||
captureEvent('wa_invite_member_card_invite_success', {
|
||||
num_emails: data.emails.length,
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [domain, form, toast, router]);
|
||||
}, [domain, form, toast, router, captureEvent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -151,7 +159,9 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
|
|||
</div>
|
||||
</div>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel onClick={() => captureEvent('wa_invite_member_card_invite_cancel', {
|
||||
num_emails: form.getValues().emails.length,
|
||||
})}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => onSubmit(form.getValues())}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { useCallback, useMemo, useState } from "react";
|
|||
import { cancelInvite } from "@/actions";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useDomain } from "@/hooks/useDomain";
|
||||
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
interface Invite {
|
||||
id: string;
|
||||
email: string;
|
||||
|
|
@ -36,6 +36,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
|
|||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const domain = useDomain();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const filteredInvites = useMemo(() => {
|
||||
return invites
|
||||
|
|
@ -59,14 +60,18 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
|
|||
toast({
|
||||
description: `❌ Failed to cancel invite. Reason: ${response.message}`
|
||||
})
|
||||
captureEvent('wa_invites_list_cancel_invite_fail', {
|
||||
error: response.errorCode,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Invite cancelled successfully.`
|
||||
})
|
||||
captureEvent('wa_invites_list_cancel_invite_success', {})
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}, [domain, toast, router]);
|
||||
}, [domain, toast, router, captureEvent]);
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto space-y-6">
|
||||
|
|
@ -126,11 +131,13 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
|
|||
toast({
|
||||
description: `✅ Copied invite link for ${invite.email} to clipboard`
|
||||
})
|
||||
captureEvent('wa_invites_list_copy_invite_link_success', {})
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
description: "❌ Failed to copy invite link"
|
||||
})
|
||||
captureEvent('wa_invites_list_copy_invite_link_fail', {})
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
|
@ -152,11 +159,13 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
|
|||
toast({
|
||||
description: `✅ Email copied to clipboard.`
|
||||
})
|
||||
captureEvent('wa_invites_list_copy_email_success', {})
|
||||
})
|
||||
.catch(() => {
|
||||
toast({
|
||||
description: `❌ Failed to copy email.`
|
||||
})
|
||||
captureEvent('wa_invites_list_copy_email_fail', {})
|
||||
})
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { transferOwnership, removeMemberFromOrg, leaveOrg } from "@/actions";
|
|||
import { isServiceError } from "@/lib/utils";
|
||||
import { useToast } from "@/components/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
type Member = {
|
||||
id: string
|
||||
|
|
@ -44,6 +45,7 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
|
|||
const [isTransferOwnershipDialogOpen, setIsTransferOwnershipDialogOpen] = useState(false)
|
||||
const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false)
|
||||
const router = useRouter();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
return members
|
||||
|
|
@ -68,10 +70,14 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
|
|||
toast({
|
||||
description: `❌ Failed to remove member. Reason: ${response.message}`
|
||||
})
|
||||
captureEvent('wa_members_list_remove_member_fail', {
|
||||
error: response.errorCode,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Member removed successfully.`
|
||||
})
|
||||
captureEvent('wa_members_list_remove_member_success', {})
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
|
|
@ -84,14 +90,18 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
|
|||
toast({
|
||||
description: `❌ Failed to transfer ownership. Reason: ${response.message}`
|
||||
})
|
||||
captureEvent('wa_members_list_transfer_ownership_fail', {
|
||||
error: response.errorCode,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ Ownership transferred successfully.`
|
||||
})
|
||||
captureEvent('wa_members_list_transfer_ownership_success', {})
|
||||
router.refresh();
|
||||
}
|
||||
});
|
||||
}, [domain, toast, router]);
|
||||
}, [domain, toast, router, captureEvent]);
|
||||
|
||||
const onLeaveOrg = useCallback(() => {
|
||||
leaveOrg(domain)
|
||||
|
|
@ -100,10 +110,14 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
|
|||
toast({
|
||||
description: `❌ Failed to leave organization. Reason: ${response.message}`
|
||||
})
|
||||
captureEvent('wa_members_list_leave_org_fail', {
|
||||
error: response.errorCode,
|
||||
})
|
||||
} else {
|
||||
toast({
|
||||
description: `✅ You have left the organization.`
|
||||
})
|
||||
captureEvent('wa_members_list_leave_org_success', {})
|
||||
router.push("/");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,9 +3,16 @@
|
|||
import { ENTERPRISE_FEATURES } from "@/lib/constants";
|
||||
import { UpgradeCard } from "./upgradeCard";
|
||||
import Link from "next/link";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
|
||||
export const EnterpriseUpgradeCard = () => {
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const onClick = () => {
|
||||
captureEvent('wa_enterprise_upgrade_card_pressed', {});
|
||||
}
|
||||
|
||||
return (
|
||||
<Link href="mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry">
|
||||
<UpgradeCard
|
||||
|
|
@ -15,6 +22,7 @@ export const EnterpriseUpgradeCard = () => {
|
|||
priceDescription="tailored to your needs"
|
||||
features={ENTERPRISE_FEATURES}
|
||||
buttonText="Contact Us"
|
||||
onClick={onClick}
|
||||
/>
|
||||
</Link>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { isServiceError } from "@/lib/utils";
|
|||
import { useCallback, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { TEAM_FEATURES } from "@/lib/constants";
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
interface TeamUpgradeCardProps {
|
||||
buttonText: string;
|
||||
|
|
@ -18,8 +19,10 @@ export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => {
|
|||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
captureEvent('wa_team_upgrade_card_pressed', {});
|
||||
setIsLoading(true);
|
||||
createStripeCheckoutSession(domain)
|
||||
.then((response) => {
|
||||
|
|
@ -28,14 +31,18 @@ export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => {
|
|||
description: `❌ Stripe checkout failed with error: ${response.message}`,
|
||||
variant: "destructive",
|
||||
});
|
||||
captureEvent('wa_team_upgrade_checkout_fail', {
|
||||
error: response.errorCode,
|
||||
});
|
||||
} else {
|
||||
router.push(response.url);
|
||||
captureEvent('wa_team_upgrade_checkout_success', {});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsLoading(false);
|
||||
});
|
||||
}, [domain, router, toast]);
|
||||
}, [domain, router, toast, captureEvent]);
|
||||
|
||||
return (
|
||||
<UpgradeCard
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import type { Metadata } from "next";
|
|||
import "./globals.css";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { QueryClientProvider } from "./queryClientProvider";
|
||||
import { PHProvider } from "./posthogProvider";
|
||||
import { PostHogProvider } from "./posthogProvider";
|
||||
import { Toaster } from "@/components/ui/toaster";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { SessionProvider } from "next-auth/react";
|
||||
|
|
@ -26,7 +26,7 @@ export default function RootLayout({
|
|||
<body>
|
||||
<Toaster />
|
||||
<SessionProvider>
|
||||
<PHProvider>
|
||||
<PostHogProvider>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="system"
|
||||
|
|
@ -39,7 +39,7 @@ export default function RootLayout({
|
|||
</TooltipProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</PHProvider>
|
||||
</PostHogProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -13,26 +13,35 @@ import { Loader2 } from "lucide-react"
|
|||
import { useToast } from "@/components/hooks/use-toast"
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Card } from "@/components/ui/card"
|
||||
import useCaptureEvent from "@/hooks/useCaptureEvent";
|
||||
|
||||
const onboardingFormSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, { message: "Organization name must be at least 3 characters long." })
|
||||
.max(30, { message: "Organization name must be at most 30 characters long." }),
|
||||
domain: z.string()
|
||||
.min(2, { message: "Organization domain must be at least 3 characters long." })
|
||||
.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 function OrgCreateForm() {
|
||||
const { toast } = useToast();
|
||||
const router = useRouter();
|
||||
const captureEvent = useCaptureEvent();
|
||||
|
||||
const onboardingFormSchema = z.object({
|
||||
name: z.string()
|
||||
.min(2, { message: "Organization name must be at least 3 characters long." })
|
||||
.max(30, { message: "Organization name must be at most 30 characters long." }),
|
||||
domain: z.string()
|
||||
.min(2, { message: "Organization domain must be at least 3 characters long." })
|
||||
.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);
|
||||
if (!isServiceError(doesDomainExist)) {
|
||||
captureEvent('wa_onboard_org_create_fail', {
|
||||
error: "Domain already exists",
|
||||
})
|
||||
}
|
||||
return isServiceError(doesDomainExist) || !doesDomainExist;
|
||||
}, "This domain is already taken."),
|
||||
})
|
||||
|
||||
const form = useForm<z.infer<typeof onboardingFormSchema>>({
|
||||
resolver: zodResolver(onboardingFormSchema),
|
||||
defaultValues: {
|
||||
|
|
@ -48,8 +57,12 @@ export function OrgCreateForm() {
|
|||
toast({
|
||||
description: `❌ Failed to create organization. Reason: ${response.message}`
|
||||
})
|
||||
captureEvent('wa_onboard_org_create_fail', {
|
||||
error: response.errorCode,
|
||||
})
|
||||
} else {
|
||||
router.push(`/${data.domain}/onboard`);
|
||||
captureEvent('wa_onboard_org_create_success', {})
|
||||
}
|
||||
}, [router, toast]);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,43 +1,71 @@
|
|||
'use client'
|
||||
import { NEXT_PUBLIC_POSTHOG_PAPIK, NEXT_PUBLIC_POSTHOG_UI_HOST, NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED } from '@/lib/environment.client'
|
||||
import posthog from 'posthog-js'
|
||||
import { PostHogProvider } from 'posthog-js/react'
|
||||
import { usePostHog } from 'posthog-js/react'
|
||||
import { PostHogProvider as PHProvider } from 'posthog-js/react'
|
||||
import { resolveServerPath } from './api/(client)/client'
|
||||
import { isDefined } from '@/lib/utils'
|
||||
import { usePathname, useSearchParams } from "next/navigation"
|
||||
import { useEffect, Suspense } from "react"
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
if (!NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED && isDefined(NEXT_PUBLIC_POSTHOG_PAPIK)) {
|
||||
// @see next.config.mjs for path rewrites to the "/ingest" route.
|
||||
const posthogHostPath = resolveServerPath('/ingest');
|
||||
const POSTHOG_ENABLED = isDefined(NEXT_PUBLIC_POSTHOG_PAPIK) && !NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED;
|
||||
|
||||
posthog.init(NEXT_PUBLIC_POSTHOG_PAPIK, {
|
||||
api_host: posthogHostPath,
|
||||
ui_host: NEXT_PUBLIC_POSTHOG_UI_HOST,
|
||||
person_profiles: 'identified_only',
|
||||
capture_pageview: false, // Disable automatic pageview capture
|
||||
autocapture: false, // Disable automatic event capture
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sanitize_properties: (properties: Record<string, any>, _event: string) => {
|
||||
// https://posthog.com/docs/libraries/js#config
|
||||
if (properties['$current_url']) {
|
||||
properties['$current_url'] = null;
|
||||
}
|
||||
if (properties['$ip']) {
|
||||
properties['$ip'] = null;
|
||||
}
|
||||
|
||||
return properties;
|
||||
function PostHogPageView() {
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const posthog = usePostHog()
|
||||
|
||||
// Track pageviews
|
||||
useEffect(() => {
|
||||
if (pathname && posthog) {
|
||||
let url = window.origin + pathname
|
||||
if (searchParams.toString()) {
|
||||
url = url + `?${searchParams.toString()}`
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log("PostHog telemetry disabled");
|
||||
}
|
||||
|
||||
posthog.capture('$pageview', { '$current_url': url })
|
||||
}
|
||||
}, [pathname, searchParams, posthog])
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function PHProvider({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
|
||||
export function PostHogProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (POSTHOG_ENABLED) {
|
||||
// @see next.config.mjs for path rewrites to the "/ingest" route.
|
||||
const posthogHostPath = resolveServerPath('/ingest');
|
||||
|
||||
posthog.init(NEXT_PUBLIC_POSTHOG_PAPIK!, {
|
||||
api_host: posthogHostPath,
|
||||
ui_host: NEXT_PUBLIC_POSTHOG_UI_HOST,
|
||||
capture_pageview: false, // Disable automatic pageview capture
|
||||
autocapture: false, // Disable automatic event capture
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
/* @nocheckin HANDLE SELF HOSTED CASE
|
||||
person_profiles: 'identified_only',
|
||||
sanitize_properties: (properties: Record<string, any>, _event: string) => {
|
||||
// https://posthog.com/docs/libraries/js#config
|
||||
if (properties['$current_url']) {
|
||||
properties['$current_url'] = null;
|
||||
}
|
||||
if (properties['$ip']) {
|
||||
properties['$ip'] = null;
|
||||
}
|
||||
|
||||
return properties;
|
||||
}
|
||||
*/
|
||||
});
|
||||
} else {
|
||||
console.log("PostHog telemetry disabled");
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<PHProvider client={posthog}>
|
||||
<PostHogPageView />
|
||||
{children}
|
||||
</PHProvider>
|
||||
)
|
||||
}
|
||||
|
|
@ -17,4 +17,5 @@ export enum ErrorCode {
|
|||
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
|
||||
INVALID_INVITE = 'INVALID_INVITE',
|
||||
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
|
||||
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,200 @@ export type PosthogEventMap = {
|
|||
fileLanguages: string[]
|
||||
},
|
||||
share_link_created: {},
|
||||
}
|
||||
////////////////////////////////////////////////////////////////
|
||||
wa_secret_created_success: {
|
||||
key: string,
|
||||
},
|
||||
wa_secret_deleted_success: {
|
||||
key: string,
|
||||
},
|
||||
wa_secret_deleted_fail: {
|
||||
key: string,
|
||||
error: string,
|
||||
},
|
||||
wa_secret_created_fail: {
|
||||
key: string,
|
||||
error: string,
|
||||
},
|
||||
wa_secret_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_warning_nav_connection_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_warning_nav_hover: {},
|
||||
wa_warning_nav_pressed: {},
|
||||
wa_warning_nav_connection_pressed: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_error_nav_connection_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_error_nav_hover: {},
|
||||
wa_error_nav_pressed: {},
|
||||
wa_error_nav_job_pressed: {},
|
||||
wa_error_nav_job_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_progress_nav_connection_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_progress_nav_job_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_progress_nav_hover: {},
|
||||
wa_progress_nav_pressed: {},
|
||||
wa_progress_nav_job_pressed: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_trial_nav_pressed: {},
|
||||
wa_trial_nav_subscription_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_connection_list_item_error_hover: {},
|
||||
wa_connection_list_item_error_pressed: {},
|
||||
wa_connection_list_item_warning_hover: {},
|
||||
wa_connection_list_item_warning_pressed: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_connection_list_item_manage_pressed: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_create_connection_success: {
|
||||
type: string,
|
||||
},
|
||||
wa_create_connection_fail: {
|
||||
type: string,
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_config_editor_quick_action_pressed: {
|
||||
name: string,
|
||||
type: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_secret_combobox_import_secret_pressed: {
|
||||
type: string,
|
||||
},
|
||||
wa_secret_combobox_import_secret_success: {
|
||||
type: string,
|
||||
},
|
||||
wa_secret_combobox_import_secret_fail: {
|
||||
type: string,
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_billing_email_updated_success: {},
|
||||
wa_billing_email_updated_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_billing_email_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_manage_subscription_button_create_portal_session_success: {},
|
||||
wa_manage_subscription_button_create_portal_session_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_invite_member_card_invite_success: {
|
||||
num_emails: number,
|
||||
},
|
||||
wa_invite_member_card_invite_fail: {
|
||||
error: string,
|
||||
num_emails: number,
|
||||
},
|
||||
wa_invite_member_card_invite_cancel: {
|
||||
num_emails: number,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_onboard_skip_onboarding: {
|
||||
step: string,
|
||||
},
|
||||
wa_onboard_invite_team_invite_success: {
|
||||
num_emails: number,
|
||||
},
|
||||
wa_onboard_invite_team_invite_fail: {
|
||||
error: string,
|
||||
num_emails: number,
|
||||
},
|
||||
wa_onboard_invite_team_skip: {
|
||||
num_emails: number,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_members_list_remove_member_success: {},
|
||||
wa_members_list_remove_member_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_members_list_transfer_ownership_success: {},
|
||||
wa_members_list_transfer_ownership_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_members_list_leave_org_success: {},
|
||||
wa_members_list_leave_org_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_invites_list_cancel_invite_success: {},
|
||||
wa_invites_list_cancel_invite_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_invites_list_copy_invite_link_success: {},
|
||||
wa_invites_list_copy_invite_link_fail: {},
|
||||
wa_invites_list_copy_email_success: {},
|
||||
wa_invites_list_copy_email_fail: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_onboard_org_create_success: {},
|
||||
wa_onboard_org_create_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_connect_code_host_button_pressed: {
|
||||
name: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_onboard_checkout_success: {},
|
||||
wa_onboard_checkout_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_team_upgrade_card_pressed: {},
|
||||
wa_team_upgrade_checkout_success: {},
|
||||
wa_team_upgrade_checkout_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_enterprise_upgrade_card_pressed: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_connection_delete_success: {},
|
||||
wa_connection_delete_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_connection_failed_status_hover: {},
|
||||
wa_connection_retry_sync_success: {},
|
||||
wa_connection_retry_sync_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_connection_not_found_warning_displayed: {},
|
||||
wa_connection_secrets_navigation_pressed: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_connection_retry_all_failed_repos_pressed: {},
|
||||
wa_connection_retry_all_failed_repos_fetch_fail: {
|
||||
error: string,
|
||||
},
|
||||
wa_connection_retry_all_failed_repos_fail: {
|
||||
successCount: number,
|
||||
failureCount: number,
|
||||
},
|
||||
wa_connection_retry_all_failed_repos_success: {
|
||||
successCount: number,
|
||||
},
|
||||
wa_connection_retry_all_failed_no_repos: {},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
wa_repo_retry_index_success: {},
|
||||
wa_repo_retry_index_fail: {
|
||||
error: string,
|
||||
},
|
||||
//////////////////////////////////////////////////////////////////
|
||||
}
|
||||
|
||||
export type PosthogEvent = keyof PosthogEventMap;
|
||||
|
|
@ -99,4 +99,12 @@ export const orgInvalidSubscription = (): ServiceError => {
|
|||
errorCode: ErrorCode.ORG_INVALID_SUBSCRIPTION,
|
||||
message: "Invalid subscription",
|
||||
}
|
||||
}
|
||||
|
||||
export const secretAlreadyExists = (): ServiceError => {
|
||||
return {
|
||||
statusCode: StatusCodes.CONFLICT,
|
||||
errorCode: ErrorCode.SECRET_ALREADY_EXISTS,
|
||||
message: "Secret already exists",
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue