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