fix: improve onboarding
This commit is contained in:
@@ -1,16 +1,16 @@
|
|||||||
import { LogError } from '@/utils/errors';
|
|
||||||
import {
|
import {
|
||||||
Arctic,
|
Arctic,
|
||||||
type OAuth2Tokens,
|
|
||||||
createSession,
|
createSession,
|
||||||
generateSessionToken,
|
generateSessionToken,
|
||||||
github,
|
github,
|
||||||
google,
|
google,
|
||||||
|
type OAuth2Tokens,
|
||||||
setSessionTokenCookie,
|
setSessionTokenCookie,
|
||||||
} from '@openpanel/auth';
|
} from '@openpanel/auth';
|
||||||
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
|
import { type Account, connectUserToOrganization, db } from '@openpanel/db';
|
||||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { LogError } from '@/utils/errors';
|
||||||
|
|
||||||
async function getGithubEmail(githubAccessToken: string) {
|
async function getGithubEmail(githubAccessToken: string) {
|
||||||
const emailListRequest = new Request('https://api.github.com/user/emails');
|
const emailListRequest = new Request('https://api.github.com/user/emails');
|
||||||
@@ -74,10 +74,15 @@ async function handleExistingUser({
|
|||||||
setSessionTokenCookie(
|
setSessionTokenCookie(
|
||||||
(...args) => reply.setCookie(...args),
|
(...args) => reply.setCookie(...args),
|
||||||
sessionToken,
|
sessionToken,
|
||||||
session.expiresAt,
|
session.expiresAt
|
||||||
);
|
);
|
||||||
|
reply.setCookie('last-auth-provider', providerName, {
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
return reply.redirect(
|
return reply.redirect(
|
||||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,7 +108,7 @@ async function handleNewUser({
|
|||||||
existingUser,
|
existingUser,
|
||||||
oauthUser,
|
oauthUser,
|
||||||
providerName,
|
providerName,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,10 +143,15 @@ async function handleNewUser({
|
|||||||
setSessionTokenCookie(
|
setSessionTokenCookie(
|
||||||
(...args) => reply.setCookie(...args),
|
(...args) => reply.setCookie(...args),
|
||||||
sessionToken,
|
sessionToken,
|
||||||
session.expiresAt,
|
session.expiresAt
|
||||||
);
|
);
|
||||||
|
reply.setCookie('last-auth-provider', providerName, {
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
return reply.redirect(
|
return reply.redirect(
|
||||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +229,7 @@ interface ValidatedOAuthQuery {
|
|||||||
|
|
||||||
async function validateOAuthCallback(
|
async function validateOAuthCallback(
|
||||||
req: FastifyRequest,
|
req: FastifyRequest,
|
||||||
provider: Provider,
|
provider: Provider
|
||||||
): Promise<ValidatedOAuthQuery> {
|
): Promise<ValidatedOAuthQuery> {
|
||||||
const schema = z.object({
|
const schema = z.object({
|
||||||
code: z.string(),
|
code: z.string(),
|
||||||
@@ -353,7 +363,7 @@ export async function googleCallback(req: FastifyRequest, reply: FastifyReply) {
|
|||||||
|
|
||||||
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
function redirectWithError(reply: FastifyReply, error: LogError | unknown) {
|
||||||
const url = new URL(
|
const url = new URL(
|
||||||
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!,
|
process.env.DASHBOARD_URL || process.env.NEXT_PUBLIC_DASHBOARD_URL!
|
||||||
);
|
);
|
||||||
url.pathname = '/login';
|
url.pathname = '/login';
|
||||||
if (error instanceof LogError) {
|
if (error instanceof LogError) {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { Button } from '../ui/button';
|
|||||||
const validator = zSignInEmail;
|
const validator = zSignInEmail;
|
||||||
type IForm = z.infer<typeof validator>;
|
type IForm = z.infer<typeof validator>;
|
||||||
|
|
||||||
export function SignInEmailForm() {
|
export function SignInEmailForm({ isLastUsed }: { isLastUsed?: boolean }) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.auth.signInEmail.mutationOptions({
|
trpc.auth.signInEmail.mutationOptions({
|
||||||
@@ -54,9 +54,16 @@ export function SignInEmailForm() {
|
|||||||
type="password"
|
type="password"
|
||||||
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
|
className="bg-def-100/50 border-def-300 focus:border-highlight focus:ring-highlight/20"
|
||||||
/>
|
/>
|
||||||
<Button type="submit" size="lg">
|
<div className="relative">
|
||||||
Sign in
|
<Button type="submit" size="lg" className="w-full">
|
||||||
</Button>
|
Sign in
|
||||||
|
</Button>
|
||||||
|
{isLastUsed && (
|
||||||
|
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
|
||||||
|
Used last time
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Button } from '../ui/button';
|
|||||||
export function SignInGithub({
|
export function SignInGithub({
|
||||||
type,
|
type,
|
||||||
inviteId,
|
inviteId,
|
||||||
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) {
|
isLastUsed,
|
||||||
|
}: { type: 'sign-in' | 'sign-up'; inviteId?: string; isLastUsed?: boolean }) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.auth.signInOAuth.mutationOptions({
|
trpc.auth.signInOAuth.mutationOptions({
|
||||||
@@ -21,27 +22,34 @@ export function SignInGithub({
|
|||||||
if (type === 'sign-up') return 'Sign up with Github';
|
if (type === 'sign-up') return 'Sign up with Github';
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="relative">
|
||||||
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
|
<Button
|
||||||
size="lg"
|
className="w-full bg-primary hover:bg-primary/90 text-primary-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
|
||||||
onClick={() =>
|
size="lg"
|
||||||
mutation.mutate({
|
onClick={() =>
|
||||||
provider: 'github',
|
mutation.mutate({
|
||||||
inviteId: type === 'sign-up' ? inviteId : undefined,
|
provider: 'github',
|
||||||
})
|
inviteId: type === 'sign-up' ? inviteId : undefined,
|
||||||
}
|
})
|
||||||
>
|
}
|
||||||
<svg
|
|
||||||
className="size-4 mr-2"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="24"
|
|
||||||
height="24"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="currentColor"
|
|
||||||
>
|
>
|
||||||
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
<svg
|
||||||
</svg>
|
className="size-4 mr-2"
|
||||||
{title()}
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
</Button>
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z" />
|
||||||
|
</svg>
|
||||||
|
{title()}
|
||||||
|
</Button>
|
||||||
|
{isLastUsed && (
|
||||||
|
<span className="absolute -top-2 right-3 text-[10px] font-medium bg-highlight text-white px-1.5 py-0.5 rounded-full leading-none">
|
||||||
|
Used last time
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { useTRPC } from '@/integrations/trpc/react';
|
|
||||||
import { useMutation } from '@tanstack/react-query';
|
import { useMutation } from '@tanstack/react-query';
|
||||||
import { Button } from '../ui/button';
|
import { Button } from '../ui/button';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
|
||||||
export function SignInGoogle({
|
export function SignInGoogle({
|
||||||
type,
|
type,
|
||||||
inviteId,
|
inviteId,
|
||||||
}: { type: 'sign-in' | 'sign-up'; inviteId?: string }) {
|
isLastUsed,
|
||||||
|
}: {
|
||||||
|
type: 'sign-in' | 'sign-up';
|
||||||
|
inviteId?: string;
|
||||||
|
isLastUsed?: boolean;
|
||||||
|
}) {
|
||||||
const trpc = useTRPC();
|
const trpc = useTRPC();
|
||||||
const mutation = useMutation(
|
const mutation = useMutation(
|
||||||
trpc.auth.signInOAuth.mutationOptions({
|
trpc.auth.signInOAuth.mutationOptions({
|
||||||
@@ -14,46 +19,57 @@ export function SignInGoogle({
|
|||||||
window.location.href = res.url;
|
window.location.href = res.url;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
const title = () => {
|
const title = () => {
|
||||||
if (type === 'sign-in') return 'Sign in with Google';
|
if (type === 'sign-in') {
|
||||||
if (type === 'sign-up') return 'Sign up with Google';
|
return 'Sign in with Google';
|
||||||
|
}
|
||||||
|
if (type === 'sign-up') {
|
||||||
|
return 'Sign up with Google';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<Button
|
<div className="relative">
|
||||||
className="w-full bg-background hover:bg-def-100 border border-def-300 text-foreground shadow-sm hover:shadow-md transition-all duration-200 [&_svg]:shrink-0"
|
<Button
|
||||||
size="lg"
|
className="w-full border border-def-300 bg-background text-foreground shadow-sm transition-all duration-200 hover:bg-def-100 hover:shadow-md [&_svg]:shrink-0"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
mutation.mutate({
|
mutation.mutate({
|
||||||
provider: 'google',
|
provider: 'google',
|
||||||
inviteId: type === 'sign-up' ? inviteId : undefined,
|
inviteId: type === 'sign-up' ? inviteId : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
>
|
size="lg"
|
||||||
<svg
|
|
||||||
className="size-4 mr-2"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
>
|
||||||
<path
|
<svg
|
||||||
fill="#4285F4"
|
className="mr-2 size-4"
|
||||||
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
viewBox="0 0 24 24"
|
||||||
/>
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
<path
|
>
|
||||||
fill="#34A853"
|
<path
|
||||||
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
||||||
/>
|
fill="#4285F4"
|
||||||
<path
|
/>
|
||||||
fill="#FBBC05"
|
<path
|
||||||
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
||||||
/>
|
fill="#34A853"
|
||||||
<path
|
/>
|
||||||
fill="#EA4335"
|
<path
|
||||||
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
||||||
/>
|
fill="#FBBC05"
|
||||||
</svg>
|
/>
|
||||||
{title()}
|
<path
|
||||||
</Button>
|
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
||||||
|
fill="#EA4335"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{title()}
|
||||||
|
</Button>
|
||||||
|
{isLastUsed && (
|
||||||
|
<span className="absolute -top-2 right-3 rounded-full bg-highlight px-1.5 py-0.5 font-medium text-[10px] text-white leading-none">
|
||||||
|
Used last time
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
// Based on Christin Alares tag input component (https://github.com/christianalares/seventy-seven)
|
// Based on Christin Alares tag input component (https://github.com/christianalares/seventy-seven)
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { useAnimate } from 'framer-motion';
|
import { useAnimate } from 'framer-motion';
|
||||||
import { XIcon } from 'lucide-react';
|
import { XIcon } from 'lucide-react';
|
||||||
import type { ElementRef } from 'react';
|
import type { ElementRef } from 'react';
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
value: string[];
|
value: string[];
|
||||||
error?: string;
|
error?: string;
|
||||||
@@ -15,7 +15,7 @@ type Props = {
|
|||||||
onChange: (value: string[]) => void;
|
onChange: (value: string[]) => void;
|
||||||
renderTag?: (tag: string) => string;
|
renderTag?: (tag: string) => string;
|
||||||
id?: string;
|
id?: string;
|
||||||
};
|
}
|
||||||
|
|
||||||
const TagInput = ({
|
const TagInput = ({
|
||||||
value: propValue,
|
value: propValue,
|
||||||
@@ -49,7 +49,7 @@ const TagInput = ({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
const tagAlreadyExists = value.some(
|
const tagAlreadyExists = value.some(
|
||||||
(tag) => tag.toLowerCase() === inputValue.toLowerCase(),
|
(tag) => tag.toLowerCase() === inputValue.toLowerCase()
|
||||||
);
|
);
|
||||||
|
|
||||||
if (inputValue) {
|
if (inputValue) {
|
||||||
@@ -61,7 +61,7 @@ const TagInput = ({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 0.3,
|
duration: 0.3,
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,50 +100,50 @@ const TagInput = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={scope}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex w-full flex-wrap items-center gap-2 rounded-md border border-input p-1 px-3 ring-offset-background has-[input:focus]:ring-2 has-[input:focus]:ring-ring has-[input:focus]:ring-offset-1 bg-card',
|
'inline-flex w-full flex-wrap items-center gap-2 rounded-md border border-input bg-card p-1 px-3 ring-offset-background has-[input:focus]:ring-2 has-[input:focus]:ring-ring has-[input:focus]:ring-offset-1',
|
||||||
!!error && 'border-destructive',
|
!!error && 'border-destructive'
|
||||||
)}
|
)}
|
||||||
|
ref={scope}
|
||||||
>
|
>
|
||||||
{value.map((tag, i) => {
|
{value.map((tag, i) => {
|
||||||
const isCreating = false;
|
const isCreating = false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-tag={tag}
|
|
||||||
key={tag}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1 ',
|
'inline-flex items-center gap-2 rounded bg-def-200 px-2 py-1',
|
||||||
isMarkedForDeletion &&
|
isMarkedForDeletion &&
|
||||||
i === value.length - 1 &&
|
i === value.length - 1 &&
|
||||||
'bg-destructive-foreground ring-2 ring-destructive/50 ring-offset-1',
|
'bg-destructive/15 ring-2 ring-destructive ring-offset-2 ring-offset-card',
|
||||||
isCreating && 'opacity-60',
|
isCreating && 'opacity-60'
|
||||||
)}
|
)}
|
||||||
|
data-tag={tag}
|
||||||
|
key={tag}
|
||||||
>
|
>
|
||||||
{renderTag ? renderTag(tag) : tag}
|
{renderTag ? renderTag(tag) : tag}
|
||||||
<Button
|
<Button
|
||||||
size="icon"
|
|
||||||
variant="outline"
|
|
||||||
className="h-4 w-4 rounded-full"
|
className="h-4 w-4 rounded-full"
|
||||||
onClick={() => removeTag(tag)}
|
onClick={() => removeTag(tag)}
|
||||||
|
size="icon"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
<span className="sr-only">Remove tag</span>
|
<span className="sr-only">Remove tag</span>
|
||||||
<XIcon name="close" className="size-3" />
|
<XIcon className="size-3" name="close" />
|
||||||
</Button>
|
</Button>
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
className="min-w-20 flex-1 bg-card py-1 focus-visible:outline-none"
|
||||||
placeholder={`${placeholder} ↵`}
|
id={id}
|
||||||
className="min-w-20 flex-1 py-1 focus-visible:outline-none bg-card"
|
onBlur={handleBlur}
|
||||||
value={inputValue}
|
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onBlur={handleBlur}
|
placeholder={`${placeholder} ↵`}
|
||||||
id={id}
|
ref={inputRef}
|
||||||
|
value={inputValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
import { pushModal } from '@/modals';
|
|
||||||
import { SmartphoneIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import type { IServiceClient } from '@openpanel/db';
|
|
||||||
import { frameworks } from '@openpanel/sdk-info';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
client: IServiceClient | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ConnectApp = ({ client }: Props) => {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
|
|
||||||
<SmartphoneIcon className="size-4" />
|
|
||||||
App
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mb-2">
|
|
||||||
Pick a framework below to get started.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
||||||
{frameworks
|
|
||||||
.filter((framework) => framework.type.includes('app'))
|
|
||||||
.map((framework) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
pushModal('Instructions', {
|
|
||||||
framework,
|
|
||||||
client,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
|
|
||||||
key={framework.name}
|
|
||||||
>
|
|
||||||
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
|
|
||||||
<framework.IconComponent className="h-full w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 font-semibold">{framework.name}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Missing a framework?{' '}
|
|
||||||
<a
|
|
||||||
href="mailto:hello@openpanel.dev"
|
|
||||||
className="text-foreground underline"
|
|
||||||
>
|
|
||||||
Let us know!
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConnectApp;
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { pushModal } from '@/modals';
|
|
||||||
import { ServerIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import Syntax from '@/components/syntax';
|
|
||||||
import { useAppContext } from '@/hooks/use-app-context';
|
|
||||||
import type { IServiceClient } from '@openpanel/db';
|
|
||||||
import { frameworks } from '@openpanel/sdk-info';
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
client: IServiceClient | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const ConnectBackend = ({ client }: Props) => {
|
|
||||||
const context = useAppContext();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div>
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
|
|
||||||
<ServerIcon className="size-4" />
|
|
||||||
Backend
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mb-2">
|
|
||||||
Try with a basic curl command
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Syntax
|
|
||||||
language="bash"
|
|
||||||
className="border"
|
|
||||||
code={`curl -X POST ${context.apiUrl}/track \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "openpanel-client-id: ${client?.id}" \\
|
|
||||||
-H "openpanel-client-secret: ${client?.secret}" \\
|
|
||||||
-d '{
|
|
||||||
"type": "track",
|
|
||||||
"payload": {
|
|
||||||
"name": "test_event",
|
|
||||||
"properties": {
|
|
||||||
"test": "property"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}'`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-muted-foreground mb-2">
|
|
||||||
Pick a framework below to get started.
|
|
||||||
</p>
|
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
|
||||||
{frameworks
|
|
||||||
.filter((framework) => framework.type.includes('backend'))
|
|
||||||
.map((framework) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
pushModal('Instructions', {
|
|
||||||
framework,
|
|
||||||
client,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
|
|
||||||
key={framework.name}
|
|
||||||
>
|
|
||||||
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
|
|
||||||
<framework.IconComponent className="h-full w-full" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 font-semibold">{framework.name}</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
|
||||||
Missing a framework?{' '}
|
|
||||||
<a
|
|
||||||
href="mailto:hello@openpanel.dev"
|
|
||||||
className="text-foreground underline"
|
|
||||||
>
|
|
||||||
Let us know!
|
|
||||||
</a>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ConnectBackend;
|
|
||||||
@@ -1,71 +1,80 @@
|
|||||||
import { pushModal } from '@/modals';
|
|
||||||
import { MonitorIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import Syntax from '@/components/syntax';
|
|
||||||
import type { IServiceClient } from '@openpanel/db';
|
import type { IServiceClient } from '@openpanel/db';
|
||||||
import { frameworks } from '@openpanel/sdk-info';
|
import { frameworks } from '@openpanel/sdk-info';
|
||||||
|
import { CopyIcon, PlugIcon } from 'lucide-react';
|
||||||
|
import { Button } from '../ui/button';
|
||||||
|
import Syntax from '@/components/syntax';
|
||||||
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
|
import { pushModal } from '@/modals';
|
||||||
|
import { clipboard } from '@/utils/clipboard';
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
client: IServiceClient | null;
|
client: IServiceClient | null;
|
||||||
};
|
}
|
||||||
|
|
||||||
const ConnectWeb = ({ client }: Props) => {
|
const ConnectWeb = ({ client }: Props) => {
|
||||||
return (
|
const context = useAppContext();
|
||||||
<>
|
const code = `<script>
|
||||||
<div>
|
|
||||||
<div className="flex items-center gap-2 text-xl font-bold capitalize mb-1">
|
|
||||||
<MonitorIcon className="size-4" />
|
|
||||||
Website
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground mb-2">
|
|
||||||
Paste the script to your website
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Syntax
|
|
||||||
className="border"
|
|
||||||
code={`<script>
|
|
||||||
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
window.op=window.op||function(){var n=[];return new Proxy(function(){arguments.length&&n.push([].slice.call(arguments))},{get:function(t,r){return"q"===r?n:function(){n.push([r].concat([].slice.call(arguments)))}} ,has:function(t,r){return"q"===r}}) }();
|
||||||
window.op('init', {
|
window.op('init', {${context.isSelfHosted ? `\n\tapiUrl: '${context.apiUrl}',` : ''}
|
||||||
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
|
clientId: '${client?.id ?? 'YOUR_CLIENT_ID'}',
|
||||||
trackScreenViews: true,
|
trackScreenViews: true,
|
||||||
trackOutgoingLinks: true,
|
trackOutgoingLinks: true,
|
||||||
trackAttributes: true,
|
trackAttributes: true,
|
||||||
|
// sessionReplay: {
|
||||||
|
// enabled: true,
|
||||||
|
// },
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
<script src="https://openpanel.dev/op1.js" defer async></script>`}
|
<script src="https://openpanel.dev/op1.js" defer async></script>`;
|
||||||
/>
|
return (
|
||||||
|
<>
|
||||||
|
<div className="col gap-2">
|
||||||
|
<div className="row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 font-bold text-xl capitalize">
|
||||||
|
<PlugIcon className="size-4" />
|
||||||
|
Quick start
|
||||||
|
</div>
|
||||||
|
<div className="row gap-2">
|
||||||
|
<Button
|
||||||
|
icon={CopyIcon}
|
||||||
|
onClick={() => clipboard(code, null)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Syntax className="border" code={code} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="col gap-4">
|
||||||
<p className="text-muted-foreground mb-2">
|
<p className="text-center text-muted-foreground text-sm">
|
||||||
Or pick a framework below to get started.
|
Or pick a framework below to get started.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{frameworks
|
{frameworks.map((framework) => (
|
||||||
.filter((framework) => framework.type.includes('website'))
|
<button
|
||||||
.map((framework) => (
|
className="flex items-center gap-4 rounded-md border p-2 text-left"
|
||||||
<button
|
key={framework.name}
|
||||||
type="button"
|
onClick={() =>
|
||||||
onClick={() =>
|
pushModal('Instructions', {
|
||||||
pushModal('Instructions', {
|
framework,
|
||||||
framework,
|
client,
|
||||||
client,
|
})
|
||||||
})
|
}
|
||||||
}
|
type="button"
|
||||||
className="flex items-center gap-4 rounded-md border p-2 py-2 text-left"
|
>
|
||||||
key={framework.name}
|
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
|
||||||
>
|
<framework.IconComponent className="h-full w-full" />
|
||||||
<div className="h-10 w-10 rounded-md bg-def-200 p-2">
|
</div>
|
||||||
<framework.IconComponent className="h-full w-full" />
|
<div className="flex-1 font-semibold">{framework.name}</div>
|
||||||
</div>
|
</button>
|
||||||
<div className="flex-1 font-semibold">{framework.name}</div>
|
))}
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
<p className="text-center text-muted-foreground text-sm">
|
||||||
Missing a framework?{' '}
|
Missing a framework?{' '}
|
||||||
<a
|
<a
|
||||||
href="mailto:hello@openpanel.dev"
|
|
||||||
className="text-foreground underline"
|
className="text-foreground underline"
|
||||||
|
href="mailto:hello@openpanel.dev"
|
||||||
>
|
>
|
||||||
Let us know!
|
Let us know!
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
import { useAppContext } from '@/hooks/use-app-context';
|
|
||||||
import { useClientSecret } from '@/hooks/use-client-secret';
|
|
||||||
import { clipboard } from '@/utils/clipboard';
|
|
||||||
import type { IServiceProjectWithClients } from '@openpanel/db';
|
|
||||||
import Syntax from '../syntax';
|
|
||||||
import {
|
|
||||||
Accordion,
|
|
||||||
AccordionContent,
|
|
||||||
AccordionItem,
|
|
||||||
AccordionTrigger,
|
|
||||||
} from '../ui/accordion';
|
|
||||||
|
|
||||||
export function CurlPreview({
|
|
||||||
project,
|
|
||||||
}: { project: IServiceProjectWithClients }) {
|
|
||||||
const context = useAppContext();
|
|
||||||
|
|
||||||
const [secret] = useClientSecret();
|
|
||||||
const client = project.clients[0];
|
|
||||||
if (!client) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: Record<string, any> = {
|
|
||||||
type: 'track',
|
|
||||||
payload: {
|
|
||||||
name: 'screen_view',
|
|
||||||
properties: {
|
|
||||||
__title: `Testing OpenPanel - ${project.name}`,
|
|
||||||
__path: `${project.domain}`,
|
|
||||||
__referrer: `${context.dashboardUrl}`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (project.types.includes('app')) {
|
|
||||||
payload.payload.properties.__path = '/';
|
|
||||||
delete payload.payload.properties.__referrer;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (project.types.includes('backend')) {
|
|
||||||
payload.payload.name = 'test_event';
|
|
||||||
payload.payload.properties = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = `curl -X POST ${context.apiUrl}/track \\
|
|
||||||
-H "Content-Type: application/json" \\
|
|
||||||
-H "openpanel-client-id: ${client.id}" \\
|
|
||||||
-H "openpanel-client-secret: ${secret}" \\
|
|
||||||
-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\
|
|
||||||
-d '${JSON.stringify(payload)}'`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card">
|
|
||||||
<Accordion type="single" collapsible>
|
|
||||||
<AccordionItem value="item-1">
|
|
||||||
<AccordionTrigger
|
|
||||||
className="px-6"
|
|
||||||
onClick={() => {
|
|
||||||
clipboard(code, null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Try out the curl command
|
|
||||||
</AccordionTrigger>
|
|
||||||
<AccordionContent className="p-0">
|
|
||||||
<Syntax code={code} language="bash" />
|
|
||||||
</AccordionContent>
|
|
||||||
</AccordionItem>
|
|
||||||
</Accordion>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,20 @@
|
|||||||
import useWS from '@/hooks/use-ws';
|
|
||||||
import { pushModal } from '@/modals';
|
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { timeAgo } from '@/utils/date';
|
|
||||||
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
IServiceClient,
|
IServiceClient,
|
||||||
IServiceEvent,
|
IServiceEvent,
|
||||||
IServiceProject,
|
IServiceProject,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
|
import { CheckCircle2Icon, CheckIcon, Loader2 } from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import useWS from '@/hooks/use-ws';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
import { timeAgo } from '@/utils/date';
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
project: IServiceProject;
|
project: IServiceProject;
|
||||||
client: IServiceClient | null;
|
client: IServiceClient | null;
|
||||||
events: IServiceEvent[];
|
events: IServiceEvent[];
|
||||||
onVerified: (verified: boolean) => void;
|
onVerified: (verified: boolean) => void;
|
||||||
};
|
}
|
||||||
|
|
||||||
const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
||||||
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
|
const [events, setEvents] = useState<IServiceEvent[]>(_events ?? []);
|
||||||
@@ -25,7 +23,7 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
(data) => {
|
(data) => {
|
||||||
setEvents((prev) => [...prev, data]);
|
setEvents((prev) => [...prev, data]);
|
||||||
onVerified(true);
|
onVerified(true);
|
||||||
},
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const isConnected = events.length > 0;
|
const isConnected = events.length > 0;
|
||||||
@@ -34,15 +32,15 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
if (isConnected) {
|
if (isConnected) {
|
||||||
return (
|
return (
|
||||||
<CheckCircle2Icon
|
<CheckCircle2Icon
|
||||||
strokeWidth={1.2}
|
|
||||||
size={40}
|
|
||||||
className="shrink-0 text-emerald-600"
|
className="shrink-0 text-emerald-600"
|
||||||
|
size={40}
|
||||||
|
strokeWidth={1.2}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Loader2 size={40} className="shrink-0 animate-spin text-highlight" />
|
<Loader2 className="shrink-0 animate-spin text-highlight" size={40} />
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -51,24 +49,24 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex gap-6 rounded-xl p-4 md:p-6',
|
'flex gap-6 rounded-xl p-4 md:p-6',
|
||||||
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10',
|
isConnected ? 'bg-emerald-100 dark:bg-emerald-700' : 'bg-blue-500/10'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderIcon()}
|
{renderIcon()}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="text-lg font-semibold leading-normal text-foreground/90">
|
<div className="font-semibold text-foreground/90 text-lg leading-normal">
|
||||||
{isConnected ? 'Success' : 'Waiting for events'}
|
{isConnected ? 'Success' : 'Waiting for events'}
|
||||||
</div>
|
</div>
|
||||||
{isConnected ? (
|
{isConnected ? (
|
||||||
<div className="flex flex-col-reverse">
|
<div className="flex flex-col-reverse">
|
||||||
{events.length > 5 && (
|
{events.length > 5 && (
|
||||||
<div className="flex items-center gap-2 ">
|
<div className="flex items-center gap-2">
|
||||||
<CheckIcon size={14} />{' '}
|
<CheckIcon size={14} />{' '}
|
||||||
<span>{events.length - 5} more events</span>
|
<span>{events.length - 5} more events</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{events.slice(-5).map((event) => (
|
{events.slice(-5).map((event) => (
|
||||||
<div key={event.id} className="flex items-center gap-2 ">
|
<div className="flex items-center gap-2" key={event.id}>
|
||||||
<CheckIcon size={14} />{' '}
|
<CheckIcon size={14} />{' '}
|
||||||
<span className="font-medium">{event.name}</span>{' '}
|
<span className="font-medium">{event.name}</span>{' '}
|
||||||
<span className="ml-auto text-emerald-800">
|
<span className="ml-auto text-emerald-800">
|
||||||
@@ -84,23 +82,6 @@ const VerifyListener = ({ client, events: _events, onVerified }: Props) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-2 text-sm text-muted-foreground">
|
|
||||||
You can{' '}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="underline"
|
|
||||||
onClick={() => {
|
|
||||||
pushModal('OnboardingTroubleshoot', {
|
|
||||||
client,
|
|
||||||
type: 'app',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
troubleshoot
|
|
||||||
</button>{' '}
|
|
||||||
if you are having issues connecting your app.
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
190
apps/start/src/components/onboarding/verify-faq.tsx
Normal file
190
apps/start/src/components/onboarding/verify-faq.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import type { IServiceProjectWithClients } from '@openpanel/db';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import CopyInput from '../forms/copy-input';
|
||||||
|
import { WithLabel } from '../forms/input-with-label';
|
||||||
|
import TagInput from '../forms/tag-input';
|
||||||
|
import Syntax from '../syntax';
|
||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger,
|
||||||
|
} from '../ui/accordion';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '../ui/alert';
|
||||||
|
import { useAppContext } from '@/hooks/use-app-context';
|
||||||
|
import { useClientSecret } from '@/hooks/use-client-secret';
|
||||||
|
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||||
|
|
||||||
|
export function VerifyFaq({
|
||||||
|
project,
|
||||||
|
}: {
|
||||||
|
project: IServiceProjectWithClients;
|
||||||
|
}) {
|
||||||
|
const context = useAppContext();
|
||||||
|
const trpc = useTRPC();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [secret] = useClientSecret();
|
||||||
|
|
||||||
|
const updateProject = useMutation(
|
||||||
|
trpc.project.update.mutationOptions({
|
||||||
|
onError: handleError,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries(
|
||||||
|
trpc.project.getProjectWithClients.queryFilter({
|
||||||
|
projectId: project.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
toast.success('Allowed domains updated');
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const client = project.clients[0];
|
||||||
|
if (!client) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCorsChange = (newValue: string[]) => {
|
||||||
|
const normalized = newValue
|
||||||
|
.map((item: string) => {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (
|
||||||
|
trimmed.startsWith('http://') ||
|
||||||
|
trimmed.startsWith('https://') ||
|
||||||
|
trimmed === '*'
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return trimmed ? `https://${trimmed}` : trimmed;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
updateProject.mutate({ id: project.id, cors: normalized });
|
||||||
|
};
|
||||||
|
|
||||||
|
const showSecret = secret && secret !== '[CLIENT_SECRET]';
|
||||||
|
|
||||||
|
const payload: Record<string, any> = {
|
||||||
|
type: 'track',
|
||||||
|
payload: {
|
||||||
|
name: 'screen_view',
|
||||||
|
properties: {
|
||||||
|
__title: `Testing OpenPanel - ${project.name}`,
|
||||||
|
__path: `${project.domain}`,
|
||||||
|
__referrer: `${context.dashboardUrl}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (project.types.includes('app')) {
|
||||||
|
payload.payload.properties.__path = '/';
|
||||||
|
delete payload.payload.properties.__referrer;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (project.types.includes('backend')) {
|
||||||
|
payload.payload.name = 'test_event';
|
||||||
|
payload.payload.properties = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = `curl -X POST ${context.apiUrl}/track \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-H "openpanel-client-id: ${client.id}" \\
|
||||||
|
-H "openpanel-client-secret: ${secret}" \\
|
||||||
|
-H "User-Agent: ${typeof window !== 'undefined' ? window.navigator.userAgent : ''}" \\
|
||||||
|
-d '${JSON.stringify(payload)}'`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card">
|
||||||
|
<Accordion collapsible type="single">
|
||||||
|
<AccordionItem value="item-1">
|
||||||
|
<AccordionTrigger className="px-6">
|
||||||
|
No events received?
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="col gap-4 p-6 pt-2">
|
||||||
|
<p>
|
||||||
|
Don't worry, this happens to everyone. Here are a few things you
|
||||||
|
can check:
|
||||||
|
</p>
|
||||||
|
<div className="col gap-2">
|
||||||
|
<Alert>
|
||||||
|
<UserIcon size={16} />
|
||||||
|
<AlertTitle>Ensure client ID is correct</AlertTitle>
|
||||||
|
<AlertDescription className="col gap-2">
|
||||||
|
<span>
|
||||||
|
For web tracking, the <code>clientId</code> in your snippet
|
||||||
|
must match this project. Copy it here if needed:
|
||||||
|
</span>
|
||||||
|
<CopyInput
|
||||||
|
className="[&_.font-mono]:text-sm"
|
||||||
|
label="Client ID"
|
||||||
|
value={client.id}
|
||||||
|
/>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<GlobeIcon size={16} />
|
||||||
|
<AlertTitle>Correct domain configured</AlertTitle>
|
||||||
|
<AlertDescription className="col gap-2">
|
||||||
|
<span>
|
||||||
|
For websites it's important that the domain is
|
||||||
|
correctly configured. We authenticate requests based on the
|
||||||
|
domain. Update allowed domains below:
|
||||||
|
</span>
|
||||||
|
<WithLabel label="Allowed domains">
|
||||||
|
<TagInput
|
||||||
|
onChange={handleCorsChange}
|
||||||
|
placeholder="Accept events from these domains"
|
||||||
|
renderTag={(tag: string) =>
|
||||||
|
tag === '*' ? 'Accept events from any domains' : tag
|
||||||
|
}
|
||||||
|
value={project.cors ?? []}
|
||||||
|
/>
|
||||||
|
</WithLabel>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<KeyIcon size={16} />
|
||||||
|
<AlertTitle>Wrong client secret</AlertTitle>
|
||||||
|
<AlertDescription className="col gap-2">
|
||||||
|
<span>
|
||||||
|
For app and backend events you need the correct{' '}
|
||||||
|
<code>clientSecret</code>. Copy it here if needed. Never use
|
||||||
|
the client secret in web or client-side code—it would expose
|
||||||
|
your credentials.
|
||||||
|
</span>
|
||||||
|
{showSecret && (
|
||||||
|
<CopyInput
|
||||||
|
className="[&_.font-mono]:text-sm"
|
||||||
|
label="Client secret"
|
||||||
|
value={secret}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
|
<p>
|
||||||
|
Still have issues? Join our{' '}
|
||||||
|
<a className="underline" href="https://go.openpanel.dev/discord">
|
||||||
|
discord channel
|
||||||
|
</a>{' '}
|
||||||
|
give us an email at{' '}
|
||||||
|
<a className="underline" href="mailto:hello@openpanel.dev">
|
||||||
|
hello@openpanel.dev
|
||||||
|
</a>{' '}
|
||||||
|
and we'll help you out.
|
||||||
|
</p>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
<AccordionItem value="item-2">
|
||||||
|
<AccordionTrigger className="px-6">
|
||||||
|
Personal curl example
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="p-0">
|
||||||
|
<Syntax code={code} language="bash" />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,18 +1,7 @@
|
|||||||
import {
|
|
||||||
X_AXIS_STYLE_PROPS,
|
|
||||||
useXAxisProps,
|
|
||||||
useYAxisProps,
|
|
||||||
} from '@/components/report-chart/common/axis';
|
|
||||||
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
import { getChartColor } from '@/utils/theme';
|
|
||||||
import { sum } from '@openpanel/common';
|
import { sum } from '@openpanel/common';
|
||||||
import type { IServiceOrganization } from '@openpanel/db';
|
import type { IServiceOrganization } from '@openpanel/db';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Loader2Icon } from 'lucide-react';
|
import { Loader2Icon } from 'lucide-react';
|
||||||
import { pick } from 'ramda';
|
|
||||||
import {
|
import {
|
||||||
Bar,
|
Bar,
|
||||||
BarChart,
|
BarChart,
|
||||||
@@ -23,16 +12,24 @@ import {
|
|||||||
YAxis,
|
YAxis,
|
||||||
} from 'recharts';
|
} from 'recharts';
|
||||||
import { BarShapeBlue } from '../charts/common-bar';
|
import { BarShapeBlue } from '../charts/common-bar';
|
||||||
|
import {
|
||||||
|
useXAxisProps,
|
||||||
|
useYAxisProps,
|
||||||
|
} from '@/components/report-chart/common/axis';
|
||||||
|
import { Widget, WidgetBody, WidgetHead } from '@/components/widget';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
type Props = {
|
interface Props {
|
||||||
organization: IServiceOrganization;
|
organization: IServiceOrganization;
|
||||||
};
|
}
|
||||||
|
|
||||||
function Card({ title, value }: { title: string; value: string }) {
|
function Card({ title, value }: { title: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="col gap-2 p-4 flex-1 min-w-0" title={`${title}: ${value}`}>
|
<div className="col min-w-0 flex-1 gap-2 p-4" title={`${title}: ${value}`}>
|
||||||
<div className="text-muted-foreground truncate">{title}</div>
|
<div className="truncate text-muted-foreground">{title}</div>
|
||||||
<div className="font-mono text-xl font-bold truncate">{value}</div>
|
<div className="truncate font-bold font-mono text-xl">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -43,18 +40,20 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
const usageQuery = useQuery(
|
const usageQuery = useQuery(
|
||||||
trpc.subscription.usage.queryOptions({
|
trpc.subscription.usage.queryOptions({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine interval based on data range - use weekly if more than 30 days
|
// Determine interval based on data range - use weekly if more than 30 days
|
||||||
const getDataInterval = () => {
|
const getDataInterval = () => {
|
||||||
if (!usageQuery.data || usageQuery.data.length === 0) return 'day';
|
if (!usageQuery.data || usageQuery.data.length === 0) {
|
||||||
|
return 'day';
|
||||||
|
}
|
||||||
|
|
||||||
const dates = usageQuery.data.map((item) => new Date(item.day));
|
const dates = usageQuery.data.map((item) => new Date(item.day));
|
||||||
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
|
const minDate = new Date(Math.min(...dates.map((d) => d.getTime())));
|
||||||
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
const maxDate = new Date(Math.max(...dates.map((d) => d.getTime())));
|
||||||
const daysDiff = Math.ceil(
|
const daysDiff = Math.ceil(
|
||||||
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24),
|
(maxDate.getTime() - minDate.getTime()) / (1000 * 60 * 60 * 24)
|
||||||
);
|
);
|
||||||
|
|
||||||
return daysDiff > 30 ? 'week' : 'day';
|
return daysDiff > 30 ? 'week' : 'day';
|
||||||
@@ -78,7 +77,7 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
return wrapper(
|
return wrapper(
|
||||||
<div className="center-center p-8">
|
<div className="center-center p-8">
|
||||||
<Loader2Icon className="animate-spin" />
|
<Loader2Icon className="animate-spin" />
|
||||||
</div>,
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,13 +85,16 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
return wrapper(
|
return wrapper(
|
||||||
<div className="center-center p-8 font-medium">
|
<div className="center-center p-8 font-medium">
|
||||||
Issues loading usage data
|
Issues loading usage data
|
||||||
</div>,
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usageQuery.data?.length === 0) {
|
if (
|
||||||
|
usageQuery.data?.length === 0 ||
|
||||||
|
!usageQuery.data?.some((item) => item.count !== 0)
|
||||||
|
) {
|
||||||
return wrapper(
|
return wrapper(
|
||||||
<div className="center-center p-8 font-medium">No usage data yet</div>,
|
<div className="center-center p-8 font-medium">No usage data yet</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +107,9 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
|
|
||||||
// Group daily data into weekly intervals if data spans more than 30 days
|
// Group daily data into weekly intervals if data spans more than 30 days
|
||||||
const processChartData = () => {
|
const processChartData = () => {
|
||||||
if (!usageQuery.data) return [];
|
if (!usageQuery.data) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (useWeeklyIntervals) {
|
if (useWeeklyIntervals) {
|
||||||
// Group daily data into weekly intervals
|
// Group daily data into weekly intervals
|
||||||
@@ -157,7 +161,7 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
Math.max(
|
Math.max(
|
||||||
subscriptionPeriodEventsLimit,
|
subscriptionPeriodEventsLimit,
|
||||||
subscriptionPeriodEventsCount,
|
subscriptionPeriodEventsCount,
|
||||||
...chartData.map((item) => item.count),
|
...chartData.map((item) => item.count)
|
||||||
),
|
),
|
||||||
] as [number, number];
|
] as [number, number];
|
||||||
|
|
||||||
@@ -165,7 +169,7 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
|
|
||||||
return wrapper(
|
return wrapper(
|
||||||
<>
|
<>
|
||||||
<div className="-m-4 mb-4 grid grid-cols-2 [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border border-b">
|
<div className="-m-4 mb-4 grid grid-cols-2 border-b [&>div]:shadow-[0_0_0_0.5px] [&_div]:shadow-border">
|
||||||
{organization.hasSubscription ? (
|
{organization.hasSubscription ? (
|
||||||
<>
|
<>
|
||||||
<Card
|
<Card
|
||||||
@@ -186,7 +190,7 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
1 -
|
1 -
|
||||||
subscriptionPeriodEventsCount /
|
subscriptionPeriodEventsCount /
|
||||||
subscriptionPeriodEventsLimit,
|
subscriptionPeriodEventsLimit,
|
||||||
'%',
|
'%'
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -208,7 +212,7 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
<Card
|
<Card
|
||||||
title="Events from last 30 days"
|
title="Events from last 30 days"
|
||||||
value={number.format(
|
value={number.format(
|
||||||
sum(usageQuery.data?.map((item) => item.count) ?? []),
|
sum(usageQuery.data?.map((item) => item.count) ?? [])
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -217,12 +221,12 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
{/* Events Chart */}
|
{/* Events Chart */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h3 className="text-sm font-medium text-muted-foreground">
|
<h3 className="font-medium text-muted-foreground text-sm">
|
||||||
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
|
{useWeeklyIntervals ? 'Weekly Events' : 'Daily Events'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="max-h-[300px] h-[250px] w-full p-4">
|
<div className="h-[250px] max-h-[300px] w-full p-4">
|
||||||
<ResponsiveContainer>
|
<ResponsiveContainer>
|
||||||
<BarChart data={chartData} barSize={useWeeklyIntervals ? 20 : 8}>
|
<BarChart barSize={useWeeklyIntervals ? 20 : 8} data={chartData}>
|
||||||
<RechartTooltip
|
<RechartTooltip
|
||||||
content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
|
content={<EventsTooltip useWeekly={useWeeklyIntervals} />}
|
||||||
cursor={{
|
cursor={{
|
||||||
@@ -239,15 +243,15 @@ export default function BillingUsage({ organization }: Props) {
|
|||||||
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
|
<YAxis {...yAxisProps} domain={[0, 'dataMax']} />
|
||||||
<CartesianGrid
|
<CartesianGrid
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
vertical={false}
|
|
||||||
strokeDasharray="3 3"
|
strokeDasharray="3 3"
|
||||||
strokeOpacity={0.5}
|
strokeOpacity={0.5}
|
||||||
|
vertical={false}
|
||||||
/>
|
/>
|
||||||
</BarChart>
|
</BarChart>
|
||||||
</ResponsiveContainer>
|
</ResponsiveContainer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>,
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -261,7 +265,7 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
{useWeekly && payload.weekRange
|
{useWeekly && payload.weekRange
|
||||||
? payload.weekRange
|
? payload.weekRange
|
||||||
: payload?.date
|
: payload?.date
|
||||||
@@ -271,10 +275,10 @@ function EventsTooltip({ useWeekly, ...props }: { useWeekly: boolean } & any) {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-10 w-1 rounded-full bg-chart-0" />
|
<div className="h-10 w-1 rounded-full bg-chart-0" />
|
||||||
<div className="col gap-1">
|
<div className="col gap-1">
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-muted-foreground text-sm">
|
||||||
Events {useWeekly ? 'this week' : 'this day'}
|
Events {useWeekly ? 'this week' : 'this day'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg font-semibold text-chart-0">
|
<div className="font-semibold text-chart-0 text-lg">
|
||||||
{number.format(payload.count)}
|
{number.format(payload.count)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -293,22 +297,22 @@ function TotalTooltip(props: any) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
<div className="flex min-w-[180px] flex-col gap-2 rounded-xl border bg-card p-3 shadow-xl">
|
||||||
<div className="text-sm text-muted-foreground">Total Events</div>
|
<div className="text-muted-foreground text-sm">Total Events</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-10 w-1 rounded-full bg-chart-2" />
|
<div className="h-10 w-1 rounded-full bg-chart-2" />
|
||||||
<div className="col gap-1">
|
<div className="col gap-1">
|
||||||
<div className="text-sm text-muted-foreground">Your events count</div>
|
<div className="text-muted-foreground text-sm">Your events count</div>
|
||||||
<div className="text-lg font-semibold text-chart-2">
|
<div className="font-semibold text-chart-2 text-lg">
|
||||||
{number.format(payload.count)}
|
{number.format(payload.count)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{payload.limit > 0 && (
|
{payload.limit > 0 && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="h-10 w-1 rounded-full border-2 border-dashed border-chart-1" />
|
<div className="h-10 w-1 rounded-full border-2 border-chart-1 border-dashed" />
|
||||||
<div className="col gap-1">
|
<div className="col gap-1">
|
||||||
<div className="text-sm text-muted-foreground">Your tier limit</div>
|
<div className="text-muted-foreground text-sm">Your tier limit</div>
|
||||||
<div className="text-lg font-semibold text-chart-1">
|
<div className="font-semibold text-chart-1 text-lg">
|
||||||
{number.format(payload.limit)}
|
{number.format(payload.limit)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { useNumber } from '@/hooks/use-numer-formatter';
|
|
||||||
import useWS from '@/hooks/use-ws';
|
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
|
||||||
import { pushModal, useOnPushModal } from '@/modals';
|
|
||||||
import { formatDate } from '@/utils/date';
|
|
||||||
import type { IServiceOrganization } from '@openpanel/db';
|
import type { IServiceOrganization } from '@openpanel/db';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
@@ -14,6 +8,12 @@ import { Progress } from '../ui/progress';
|
|||||||
import { Widget, WidgetBody, WidgetHead } from '../widget';
|
import { Widget, WidgetBody, WidgetHead } from '../widget';
|
||||||
import { BillingFaq } from './billing-faq';
|
import { BillingFaq } from './billing-faq';
|
||||||
import BillingUsage from './billing-usage';
|
import BillingUsage from './billing-usage';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useNumber } from '@/hooks/use-numer-formatter';
|
||||||
|
import useWS from '@/hooks/use-ws';
|
||||||
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { pushModal, useOnPushModal } from '@/modals';
|
||||||
|
import { formatDate } from '@/utils/date';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
organization: IServiceOrganization;
|
organization: IServiceOrganization;
|
||||||
@@ -28,13 +28,13 @@ export default function Billing({ organization }: Props) {
|
|||||||
const productsQuery = useQuery(
|
const productsQuery = useQuery(
|
||||||
trpc.subscription.products.queryOptions({
|
trpc.subscription.products.queryOptions({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const currentProductQuery = useQuery(
|
const currentProductQuery = useQuery(
|
||||||
trpc.subscription.getCurrent.queryOptions({
|
trpc.subscription.getCurrent.queryOptions({
|
||||||
organizationId: organization.id,
|
organizationId: organization.id,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const portalMutation = useMutation(
|
const portalMutation = useMutation(
|
||||||
@@ -47,7 +47,7 @@ export default function Billing({ organization }: Props) {
|
|||||||
onError(error) {
|
onError(error) {
|
||||||
toast.error(error.message);
|
toast.error(error.message);
|
||||||
},
|
},
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
useWS(`/live/organization/${organization.id}`, () => {
|
useWS(`/live/organization/${organization.id}`, () => {
|
||||||
@@ -55,7 +55,7 @@ export default function Billing({ organization }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
|
const [recurringInterval, setRecurringInterval] = useState<'year' | 'month'>(
|
||||||
(organization.subscriptionInterval as 'year' | 'month') || 'month',
|
(organization.subscriptionInterval as 'year' | 'month') || 'month'
|
||||||
);
|
);
|
||||||
|
|
||||||
const products = useMemo(() => {
|
const products = useMemo(() => {
|
||||||
@@ -66,7 +66,7 @@ export default function Billing({ organization }: Props) {
|
|||||||
|
|
||||||
const currentProduct = currentProductQuery.data ?? null;
|
const currentProduct = currentProductQuery.data ?? null;
|
||||||
const currentPrice = currentProduct?.prices.flatMap((p) =>
|
const currentPrice = currentProduct?.prices.flatMap((p) =>
|
||||||
p.type === 'recurring' && p.amountType === 'fixed' ? [p] : [],
|
p.type === 'recurring' && p.amountType === 'fixed' ? [p] : []
|
||||||
)[0];
|
)[0];
|
||||||
|
|
||||||
const renderStatus = () => {
|
const renderStatus = () => {
|
||||||
@@ -138,12 +138,12 @@ export default function Billing({ organization }: Props) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
<div className="col gap-8">
|
<div className="col gap-8">
|
||||||
{currentProduct && currentPrice ? (
|
{currentProduct && currentPrice ? (
|
||||||
<Widget className="w-full">
|
<Widget className="w-full">
|
||||||
<WidgetHead className="flex items-center justify-between gap-4">
|
<WidgetHead className="flex items-center justify-between gap-4">
|
||||||
<div className="flex-1 title truncate">{currentProduct.name}</div>
|
<div className="title flex-1 truncate">{currentProduct.name}</div>
|
||||||
<div className="text-lg">
|
<div className="text-lg">
|
||||||
<span className="font-bold">
|
<span className="font-bold">
|
||||||
{number.currency(currentPrice.priceAmount / 100)}
|
{number.currency(currentPrice.priceAmount / 100)}
|
||||||
@@ -157,58 +157,58 @@ export default function Billing({ organization }: Props) {
|
|||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
{renderStatus()}
|
{renderStatus()}
|
||||||
<div className="col mt-4">
|
<div className="col mt-4">
|
||||||
<div className="font-semibold mb-2">
|
<div className="mb-2 font-semibold">
|
||||||
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
|
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
|
||||||
{number.format(Number(currentProduct.metadata.eventsLimit))}
|
{number.format(Number(currentProduct.metadata.eventsLimit))}
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
|
size="sm"
|
||||||
value={
|
value={
|
||||||
(organization.subscriptionPeriodEventsCount /
|
(organization.subscriptionPeriodEventsCount /
|
||||||
Number(currentProduct.metadata.eventsLimit)) *
|
Number(currentProduct.metadata.eventsLimit)) *
|
||||||
100
|
100
|
||||||
}
|
}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
<div className="row justify-between mt-4">
|
<div className="row mt-4 justify-between">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
portalMutation.mutate({ organizationId: organization.id })
|
portalMutation.mutate({ organizationId: organization.id })
|
||||||
}
|
}
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="size-4 mr-2"
|
className="mr-2 size-4"
|
||||||
width="300"
|
fill="none"
|
||||||
height="300"
|
height="300"
|
||||||
viewBox="0 0 300 300"
|
viewBox="0 0 300 300"
|
||||||
fill="none"
|
width="300"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
>
|
>
|
||||||
<g clip-path="url(#clip0_1_4)">
|
<g clip-path="url(#clip0_1_4)">
|
||||||
<path
|
<path
|
||||||
fill-rule="evenodd"
|
|
||||||
clip-rule="evenodd"
|
clip-rule="evenodd"
|
||||||
d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z"
|
d="M66.4284 274.26C134.876 320.593 227.925 302.666 274.258 234.219C320.593 165.771 302.666 72.7222 234.218 26.3885C165.77 -19.9451 72.721 -2.0181 26.3873 66.4297C-19.9465 134.877 -2.01938 227.927 66.4284 274.26ZM47.9555 116.67C30.8375 169.263 36.5445 221.893 59.2454 256.373C18.0412 217.361 7.27564 150.307 36.9437 92.318C55.9152 55.2362 87.5665 29.3937 122.5 18.3483C90.5911 36.7105 62.5549 71.8144 47.9555 116.67ZM175.347 283.137C211.377 272.606 244.211 246.385 263.685 208.322C293.101 150.825 282.768 84.4172 242.427 45.2673C264.22 79.7626 269.473 131.542 252.631 183.287C237.615 229.421 208.385 265.239 175.347 283.137ZM183.627 266.229C207.945 245.418 228.016 210.604 236.936 168.79C251.033 102.693 232.551 41.1978 195.112 20.6768C214.97 47.3945 225.022 99.2902 218.824 157.333C214.085 201.724 200.814 240.593 183.627 266.229ZM63.7178 131.844C49.5155 198.43 68.377 260.345 106.374 280.405C85.9962 254.009 75.5969 201.514 81.8758 142.711C86.5375 99.0536 99.4504 60.737 116.225 35.0969C92.2678 55.983 72.5384 90.4892 63.7178 131.844ZM199.834 149.561C200.908 217.473 179.59 272.878 152.222 273.309C124.853 273.742 101.797 219.039 100.724 151.127C99.6511 83.2138 120.968 27.8094 148.337 27.377C175.705 26.9446 198.762 81.648 199.834 149.561Z"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
|
fill-rule="evenodd"
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id="clip0_1_4">
|
<clipPath id="clip0_1_4">
|
||||||
<rect width="300" height="300" fill="white" />
|
<rect fill="white" height="300" width="300" />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
Customer portal
|
Customer portal
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
pushModal('SelectBillingPlan', {
|
pushModal('SelectBillingPlan', {
|
||||||
organization,
|
organization,
|
||||||
currentProduct,
|
currentProduct,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
{organization.isWillBeCanceled
|
{organization.isWillBeCanceled
|
||||||
? 'Reactivate subscription'
|
? 'Reactivate subscription'
|
||||||
@@ -221,15 +221,13 @@ export default function Billing({ organization }: Props) {
|
|||||||
) : (
|
) : (
|
||||||
<Widget className="w-full">
|
<Widget className="w-full">
|
||||||
<WidgetHead className="flex items-center justify-between">
|
<WidgetHead className="flex items-center justify-between">
|
||||||
<div className="font-bold text-lg flex-1">
|
<div className="flex-1 font-bold text-lg">
|
||||||
{organization.isTrial
|
{organization.isTrial
|
||||||
? 'Get started'
|
? 'Get started'
|
||||||
: 'No active subscription'}
|
: 'No active subscription'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-lg">
|
<div className="text-muted-foreground">
|
||||||
<span className="">
|
{organization.isTrial ? '30 days free trial' : ''}
|
||||||
{organization.isTrial ? '30 days free trial' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</WidgetHead>
|
</WidgetHead>
|
||||||
<WidgetBody>
|
<WidgetBody>
|
||||||
@@ -239,7 +237,7 @@ export default function Billing({ organization }: Props) {
|
|||||||
{formatDate(organization.subscriptionEndsAt)} (
|
{formatDate(organization.subscriptionEndsAt)} (
|
||||||
{differenceInDays(
|
{differenceInDays(
|
||||||
organization.subscriptionEndsAt,
|
organization.subscriptionEndsAt,
|
||||||
new Date(),
|
new Date()
|
||||||
) + 1}{' '}
|
) + 1}{' '}
|
||||||
days left)
|
days left)
|
||||||
</p>
|
</p>
|
||||||
@@ -250,29 +248,29 @@ export default function Billing({ organization }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="col mt-4">
|
<div className="col mt-4">
|
||||||
<div className="font-semibold mb-2">
|
<div className="mb-2 font-semibold">
|
||||||
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
|
{number.format(organization.subscriptionPeriodEventsCount)} /{' '}
|
||||||
{number.format(
|
{number.format(
|
||||||
Number(organization.subscriptionPeriodEventsLimit),
|
Number(organization.subscriptionPeriodEventsLimit)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Progress
|
<Progress
|
||||||
|
size="sm"
|
||||||
value={
|
value={
|
||||||
(organization.subscriptionPeriodEventsCount /
|
(organization.subscriptionPeriodEventsCount /
|
||||||
Number(organization.subscriptionPeriodEventsLimit)) *
|
Number(organization.subscriptionPeriodEventsLimit)) *
|
||||||
100
|
100
|
||||||
}
|
}
|
||||||
size="sm"
|
|
||||||
/>
|
/>
|
||||||
<div className="row justify-end mt-4">
|
<div className="row mt-4 justify-end">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
pushModal('SelectBillingPlan', {
|
pushModal('SelectBillingPlan', {
|
||||||
organization,
|
organization,
|
||||||
currentProduct,
|
currentProduct,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
size="sm"
|
||||||
>
|
>
|
||||||
Upgrade
|
Upgrade
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
export function SkeletonDashboard() {
|
export function SkeletonDashboard() {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-gradient-to-br from-def-100 to-def-200 overflow-hidden">
|
<div className="fixed inset-0 overflow-hidden bg-gradient-to-br from-def-100 to-def-200">
|
||||||
<div className="inset-0 fixed backdrop-blur-xs bg-background/10 z-10" />
|
<div className="fixed inset-0 z-10 bg-background/10 backdrop-blur-xs" />
|
||||||
{/* Sidebar Skeleton */}
|
{/* Sidebar Skeleton */}
|
||||||
<div className="fixed left-0 top-0 w-72 h-full bg-background/80 border-r border-def-300/50 backdrop-blur-sm">
|
<div className="fixed top-0 left-0 h-full w-72 border-def-300/50 border-r bg-background/80 backdrop-blur-sm">
|
||||||
{/* Logo area */}
|
{/* Logo area */}
|
||||||
<div className="h-16 border-b border-def-300/50 flex items-center px-4">
|
<div className="flex h-16 items-center border-def-300/50 border-b px-4">
|
||||||
<div className="w-8 h-8 bg-def-300/60 rounded-lg" />
|
<div className="h-8 w-8 rounded-lg bg-def-300/60" />
|
||||||
<div className="ml-3 w-24 h-4 bg-def-300/60 rounded" />
|
<div className="ml-3 h-4 w-24 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation items */}
|
{/* Navigation items */}
|
||||||
<div className="p-4 space-y-3">
|
<div className="space-y-3 p-4">
|
||||||
{[
|
{[
|
||||||
'Dashboard',
|
'Dashboard',
|
||||||
'Analytics',
|
'Analytics',
|
||||||
@@ -21,28 +21,28 @@ export function SkeletonDashboard() {
|
|||||||
'Projects',
|
'Projects',
|
||||||
].map((item, i) => (
|
].map((item, i) => (
|
||||||
<div
|
<div
|
||||||
|
className="flex items-center space-x-3 rounded-lg p-2"
|
||||||
key={`nav-${item.toLowerCase()}`}
|
key={`nav-${item.toLowerCase()}`}
|
||||||
className="flex items-center space-x-3 p-2 rounded-lg"
|
|
||||||
>
|
>
|
||||||
<div className="w-5 h-5 bg-def-300/60 rounded" />
|
<div className="h-5 w-5 rounded bg-def-300/60" />
|
||||||
<div className="w-20 h-3 bg-def-300/60 rounded" />
|
<div className="h-3 w-20 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Project section */}
|
{/* Project section */}
|
||||||
<div className="px-4 py-2">
|
<div className="px-4 py-2">
|
||||||
<div className="w-16 h-3 bg-def-300/60 rounded mb-3" />
|
<div className="mb-3 h-3 w-16 rounded bg-def-300/60" />
|
||||||
{['Project Alpha', 'Project Beta', 'Project Gamma'].map(
|
{['Project Alpha', 'Project Beta', 'Project Gamma'].map(
|
||||||
(project, i) => (
|
(project, i) => (
|
||||||
<div
|
<div
|
||||||
|
className="mb-2 flex items-center space-x-3 rounded-lg p-2"
|
||||||
key={`project-${project.toLowerCase().replace(' ', '-')}`}
|
key={`project-${project.toLowerCase().replace(' ', '-')}`}
|
||||||
className="flex items-center space-x-3 p-2 rounded-lg mb-2"
|
|
||||||
>
|
>
|
||||||
<div className="w-4 h-4 bg-def-300/60 rounded" />
|
<div className="h-4 w-4 rounded bg-def-300/60" />
|
||||||
<div className="w-24 h-3 bg-def-300/60 rounded" />
|
<div className="h-3 w-24 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
),
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,17 +51,17 @@ export function SkeletonDashboard() {
|
|||||||
<div className="ml-72 p-8">
|
<div className="ml-72 p-8">
|
||||||
{/* Header area */}
|
{/* Header area */}
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<div className="w-48 h-6 bg-def-300/60 rounded" />
|
<div className="h-6 w-48 rounded bg-def-300/60" />
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<div className="w-20 h-8 bg-def-300/60 rounded" />
|
<div className="h-8 w-20 rounded bg-def-300/60" />
|
||||||
<div className="w-20 h-8 bg-def-300/60 rounded" />
|
<div className="h-8 w-20 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dashboard grid */}
|
{/* Dashboard grid */}
|
||||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
<div className="mb-8 grid grid-cols-3 gap-6">
|
||||||
{[
|
{[
|
||||||
'Total Users',
|
'Total Users',
|
||||||
'Active Sessions',
|
'Active Sessions',
|
||||||
@@ -71,36 +71,36 @@ export function SkeletonDashboard() {
|
|||||||
'Revenue',
|
'Revenue',
|
||||||
].map((metric, i) => (
|
].map((metric, i) => (
|
||||||
<div
|
<div
|
||||||
|
className="rounded-xl border border-def-300/50 bg-card/60 p-6"
|
||||||
key={`metric-${metric.toLowerCase().replace(' ', '-')}`}
|
key={`metric-${metric.toLowerCase().replace(' ', '-')}`}
|
||||||
className="bg-card/60 rounded-xl p-6 border border-def-300/50"
|
|
||||||
>
|
>
|
||||||
<div className="w-16 h-4 bg-def-300/60 rounded mb-3" />
|
<div className="mb-3 h-4 w-16 rounded bg-def-300/60" />
|
||||||
<div className="w-24 h-8 bg-def-300/60 rounded mb-2" />
|
<div className="mb-2 h-8 w-24 rounded bg-def-300/60" />
|
||||||
<div className="w-32 h-3 bg-def-300/60 rounded" />
|
<div className="h-3 w-32 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chart area */}
|
{/* Chart area */}
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-2 gap-6">
|
||||||
<div className="bg-card/60 rounded-xl p-6 border border-def-300/50 h-64">
|
<div className="h-64 rounded-xl border border-def-300/50 bg-card/60 p-6">
|
||||||
<div className="w-20 h-4 bg-def-300/60 rounded mb-4" />
|
<div className="mb-4 h-4 w-20 rounded bg-def-300/60" />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{['Desktop', 'Mobile', 'Tablet', 'Other'].map((device, i) => (
|
{['Desktop', 'Mobile', 'Tablet', 'Other'].map((device, i) => (
|
||||||
<div
|
<div
|
||||||
key={`chart-${device.toLowerCase()}`}
|
|
||||||
className="flex items-center space-x-3"
|
className="flex items-center space-x-3"
|
||||||
|
key={`chart-${device.toLowerCase()}`}
|
||||||
>
|
>
|
||||||
<div className="w-3 h-3 bg-def-300/60 rounded-full" />
|
<div className="h-3 w-3 rounded-full bg-def-300/60" />
|
||||||
<div className="flex-1 h-2 bg-def-300/60 rounded" />
|
<div className="h-2 flex-1 rounded bg-def-300/60" />
|
||||||
<div className="w-8 h-3 bg-def-300/60 rounded" />
|
<div className="h-3 w-8 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card/60 rounded-xl p-6 border border-def-300/50 h-64">
|
<div className="h-64 rounded-xl border border-def-300/50 bg-card/60 p-6">
|
||||||
<div className="w-20 h-4 bg-def-300/60 rounded mb-4" />
|
<div className="mb-4 h-4 w-20 rounded bg-def-300/60" />
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{[
|
{[
|
||||||
'John Doe',
|
'John Doe',
|
||||||
@@ -110,15 +110,15 @@ export function SkeletonDashboard() {
|
|||||||
'Charlie Wilson',
|
'Charlie Wilson',
|
||||||
].map((user, i) => (
|
].map((user, i) => (
|
||||||
<div
|
<div
|
||||||
key={`user-${user.toLowerCase().replace(' ', '-')}`}
|
|
||||||
className="flex items-center space-x-3"
|
className="flex items-center space-x-3"
|
||||||
|
key={`user-${user.toLowerCase().replace(' ', '-')}`}
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 bg-def-300/60 rounded-full" />
|
<div className="h-8 w-8 rounded-full bg-def-300/60" />
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="w-24 h-3 bg-def-300/60 rounded mb-1" />
|
<div className="mb-1 h-3 w-24 rounded bg-def-300/60" />
|
||||||
<div className="w-16 h-2 bg-def-300/60 rounded" />
|
<div className="h-2 w-16 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
<div className="w-12 h-3 bg-def-300/60 rounded" />
|
<div className="h-3 w-12 rounded bg-def-300/60" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import { clipboard } from '@/utils/clipboard';
|
|
||||||
import { cn } from '@/utils/cn';
|
|
||||||
import { CopyIcon } from 'lucide-react';
|
import { CopyIcon } from 'lucide-react';
|
||||||
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||||
import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash';
|
import bash from 'react-syntax-highlighter/dist/cjs/languages/hljs/bash';
|
||||||
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
|
import json from 'react-syntax-highlighter/dist/cjs/languages/hljs/json';
|
||||||
|
import markdown from 'react-syntax-highlighter/dist/cjs/languages/hljs/markdown';
|
||||||
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
|
import ts from 'react-syntax-highlighter/dist/cjs/languages/hljs/typescript';
|
||||||
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015';
|
import docco from 'react-syntax-highlighter/dist/cjs/styles/hljs/vs2015';
|
||||||
|
import { clipboard } from '@/utils/clipboard';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
SyntaxHighlighter.registerLanguage('typescript', ts);
|
SyntaxHighlighter.registerLanguage('typescript', ts);
|
||||||
SyntaxHighlighter.registerLanguage('json', json);
|
SyntaxHighlighter.registerLanguage('json', json);
|
||||||
SyntaxHighlighter.registerLanguage('bash', bash);
|
SyntaxHighlighter.registerLanguage('bash', bash);
|
||||||
|
SyntaxHighlighter.registerLanguage('markdown', markdown);
|
||||||
|
|
||||||
interface SyntaxProps {
|
interface SyntaxProps {
|
||||||
code: string;
|
code: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
language?: 'typescript' | 'bash' | 'json';
|
language?: 'typescript' | 'bash' | 'json' | 'markdown';
|
||||||
wrapLines?: boolean;
|
wrapLines?: boolean;
|
||||||
|
copyable?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Syntax({
|
export default function Syntax({
|
||||||
@@ -23,23 +26,23 @@ export default function Syntax({
|
|||||||
className,
|
className,
|
||||||
language = 'typescript',
|
language = 'typescript',
|
||||||
wrapLines = false,
|
wrapLines = false,
|
||||||
|
copyable = true,
|
||||||
}: SyntaxProps) {
|
}: SyntaxProps) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('group relative rounded-lg', className)}>
|
<div className={cn('group relative rounded-lg', className)}>
|
||||||
<button
|
{copyable && (
|
||||||
type="button"
|
<button
|
||||||
className="absolute right-1 top-1 rounded bg-card p-2 opacity-0 transition-opacity group-hover:opacity-100 row items-center gap-2"
|
className="row absolute top-1 right-1 items-center gap-2 rounded bg-card p-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
clipboard(code);
|
clipboard(code, null);
|
||||||
}}
|
}}
|
||||||
>
|
type="button"
|
||||||
<span>Copy</span>
|
>
|
||||||
<CopyIcon size={12} />
|
<span>Copy</span>
|
||||||
</button>
|
<CopyIcon size={12} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<SyntaxHighlighter
|
<SyntaxHighlighter
|
||||||
wrapLongLines={wrapLines}
|
|
||||||
style={docco}
|
|
||||||
language={language}
|
|
||||||
customStyle={{
|
customStyle={{
|
||||||
borderRadius: 'var(--radius)',
|
borderRadius: 'var(--radius)',
|
||||||
padding: '1rem',
|
padding: '1rem',
|
||||||
@@ -48,6 +51,9 @@ export default function Syntax({
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: 1.3,
|
lineHeight: 1.3,
|
||||||
}}
|
}}
|
||||||
|
language={language}
|
||||||
|
style={docco}
|
||||||
|
wrapLongLines={wrapLines}
|
||||||
>
|
>
|
||||||
{code}
|
{code}
|
||||||
</SyntaxHighlighter>
|
</SyntaxHighlighter>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useRouteContext } from '@tanstack/react-router';
|
import { useRouteContext } from '@tanstack/react-router';
|
||||||
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start';
|
import { createServerFn } from '@tanstack/react-start';
|
||||||
import { getCookies, setCookie } from '@tanstack/react-start/server';
|
import { getCookies, setCookie } from '@tanstack/react-start/server';
|
||||||
import { pick } from 'ramda';
|
import { pick } from 'ramda';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
@@ -11,6 +11,7 @@ const VALID_COOKIES = [
|
|||||||
'range',
|
'range',
|
||||||
'supporter-prompt-closed',
|
'supporter-prompt-closed',
|
||||||
'feedback-prompt-seen',
|
'feedback-prompt-seen',
|
||||||
|
'last-auth-provider',
|
||||||
] as const;
|
] as const;
|
||||||
const COOKIE_EVENT_NAME = '__cookie-change';
|
const COOKIE_EVENT_NAME = '__cookie-change';
|
||||||
|
|
||||||
@@ -20,7 +21,7 @@ const setCookieFn = createServerFn({ method: 'POST' })
|
|||||||
key: z.enum(VALID_COOKIES),
|
key: z.enum(VALID_COOKIES),
|
||||||
value: z.string(),
|
value: z.string(),
|
||||||
maxAge: z.number().optional(),
|
maxAge: z.number().optional(),
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.handler(({ data: { key, value, maxAge } }) => {
|
.handler(({ data: { key, value, maxAge } }) => {
|
||||||
if (!VALID_COOKIES.includes(key)) {
|
if (!VALID_COOKIES.includes(key)) {
|
||||||
@@ -37,13 +38,13 @@ const setCookieFn = createServerFn({ method: 'POST' })
|
|||||||
// Called in __root.tsx beforeLoad hook to get cookies from the server
|
// Called in __root.tsx beforeLoad hook to get cookies from the server
|
||||||
// And received with useRouteContext in the client
|
// And received with useRouteContext in the client
|
||||||
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
|
export const getCookiesFn = createServerFn({ method: 'GET' }).handler(() =>
|
||||||
pick(VALID_COOKIES, getCookies()),
|
pick(VALID_COOKIES, getCookies())
|
||||||
);
|
);
|
||||||
|
|
||||||
export function useCookieStore<T>(
|
export function useCookieStore<T>(
|
||||||
key: (typeof VALID_COOKIES)[number],
|
key: (typeof VALID_COOKIES)[number],
|
||||||
defaultValue: T,
|
defaultValue: T,
|
||||||
options?: { maxAge?: number },
|
options?: { maxAge?: number }
|
||||||
) {
|
) {
|
||||||
const { cookies } = useRouteContext({ strict: false });
|
const { cookies } = useRouteContext({ strict: false });
|
||||||
const [value, setValue] = useState<T>((cookies?.[key] ?? defaultValue) as T);
|
const [value, setValue] = useState<T>((cookies?.[key] ?? defaultValue) as T);
|
||||||
@@ -51,7 +52,7 @@ export function useCookieStore<T>(
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleCookieChange = (
|
const handleCookieChange = (
|
||||||
event: CustomEvent<{ key: string; value: T; from: string }>,
|
event: CustomEvent<{ key: string; value: T; from: string }>
|
||||||
) => {
|
) => {
|
||||||
if (event.detail.key === key && event.detail.from !== ref.current) {
|
if (event.detail.key === key && event.detail.from !== ref.current) {
|
||||||
setValue(event.detail.value);
|
setValue(event.detail.value);
|
||||||
@@ -60,12 +61,12 @@ export function useCookieStore<T>(
|
|||||||
|
|
||||||
window.addEventListener(
|
window.addEventListener(
|
||||||
COOKIE_EVENT_NAME,
|
COOKIE_EVENT_NAME,
|
||||||
handleCookieChange as EventListener,
|
handleCookieChange as EventListener
|
||||||
);
|
);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
COOKIE_EVENT_NAME,
|
COOKIE_EVENT_NAME,
|
||||||
handleCookieChange as EventListener,
|
handleCookieChange as EventListener
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
}, [key]);
|
}, [key]);
|
||||||
@@ -82,10 +83,10 @@ export function useCookieStore<T>(
|
|||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent(COOKIE_EVENT_NAME, {
|
new CustomEvent(COOKIE_EVENT_NAME, {
|
||||||
detail: { key, value: newValue, from: ref.current },
|
detail: { key, value: newValue, from: ref.current },
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
] as const,
|
] as const,
|
||||||
[value, key, options?.maxAge],
|
[value, key, options?.maxAge]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
import { createPushModal } from 'pushmodal';
|
import { createPushModal } from 'pushmodal';
|
||||||
|
|
||||||
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
|
|
||||||
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
|
|
||||||
import { op } from '@/utils/op';
|
|
||||||
import Instructions from './Instructions';
|
|
||||||
import AddClient from './add-client';
|
import AddClient from './add-client';
|
||||||
import AddDashboard from './add-dashboard';
|
import AddDashboard from './add-dashboard';
|
||||||
import AddImport from './add-import';
|
import AddImport from './add-import';
|
||||||
@@ -12,8 +7,8 @@ import AddNotificationRule from './add-notification-rule';
|
|||||||
import AddProject from './add-project';
|
import AddProject from './add-project';
|
||||||
import AddReference from './add-reference';
|
import AddReference from './add-reference';
|
||||||
import BillingSuccess from './billing-success';
|
import BillingSuccess from './billing-success';
|
||||||
import Confirm from './confirm';
|
|
||||||
import type { ConfirmProps } from './confirm';
|
import type { ConfirmProps } from './confirm';
|
||||||
|
import Confirm from './confirm';
|
||||||
import CreateInvite from './create-invite';
|
import CreateInvite from './create-invite';
|
||||||
import DateRangerPicker from './date-ranger-picker';
|
import DateRangerPicker from './date-ranger-picker';
|
||||||
import DateTimePicker from './date-time-picker';
|
import DateTimePicker from './date-time-picker';
|
||||||
@@ -24,7 +19,7 @@ import EditMember from './edit-member';
|
|||||||
import EditReference from './edit-reference';
|
import EditReference from './edit-reference';
|
||||||
import EditReport from './edit-report';
|
import EditReport from './edit-report';
|
||||||
import EventDetails from './event-details';
|
import EventDetails from './event-details';
|
||||||
import OnboardingTroubleshoot from './onboarding-troubleshoot';
|
import Instructions from './Instructions';
|
||||||
import OverviewChartDetails from './overview-chart-details';
|
import OverviewChartDetails from './overview-chart-details';
|
||||||
import OverviewFilters from './overview-filters';
|
import OverviewFilters from './overview-filters';
|
||||||
import RequestPasswordReset from './request-reset-password';
|
import RequestPasswordReset from './request-reset-password';
|
||||||
@@ -34,40 +29,42 @@ import ShareDashboardModal from './share-dashboard-modal';
|
|||||||
import ShareOverviewModal from './share-overview-modal';
|
import ShareOverviewModal from './share-overview-modal';
|
||||||
import ShareReportModal from './share-report-modal';
|
import ShareReportModal from './share-report-modal';
|
||||||
import ViewChartUsers from './view-chart-users';
|
import ViewChartUsers from './view-chart-users';
|
||||||
|
import OverviewTopGenericModal from '@/components/overview/overview-top-generic-modal';
|
||||||
|
import OverviewTopPagesModal from '@/components/overview/overview-top-pages-modal';
|
||||||
|
import { op } from '@/utils/op';
|
||||||
|
|
||||||
const modals = {
|
const modals = {
|
||||||
OverviewTopPagesModal: OverviewTopPagesModal,
|
OverviewTopPagesModal,
|
||||||
OverviewTopGenericModal: OverviewTopGenericModal,
|
OverviewTopGenericModal,
|
||||||
RequestPasswordReset: RequestPasswordReset,
|
RequestPasswordReset,
|
||||||
EditEvent: EditEvent,
|
EditEvent,
|
||||||
EditMember: EditMember,
|
EditMember,
|
||||||
EventDetails: EventDetails,
|
EventDetails,
|
||||||
EditClient: EditClient,
|
EditClient,
|
||||||
AddProject: AddProject,
|
AddProject,
|
||||||
AddClient: AddClient,
|
AddClient,
|
||||||
AddImport: AddImport,
|
AddImport,
|
||||||
Confirm: Confirm,
|
Confirm,
|
||||||
SaveReport: SaveReport,
|
SaveReport,
|
||||||
AddDashboard: AddDashboard,
|
AddDashboard,
|
||||||
EditDashboard: EditDashboard,
|
EditDashboard,
|
||||||
EditReport: EditReport,
|
EditReport,
|
||||||
EditReference: EditReference,
|
EditReference,
|
||||||
ShareOverviewModal: ShareOverviewModal,
|
ShareOverviewModal,
|
||||||
ShareDashboardModal: ShareDashboardModal,
|
ShareDashboardModal,
|
||||||
ShareReportModal: ShareReportModal,
|
ShareReportModal,
|
||||||
AddReference: AddReference,
|
AddReference,
|
||||||
ViewChartUsers: ViewChartUsers,
|
ViewChartUsers,
|
||||||
Instructions: Instructions,
|
Instructions,
|
||||||
OnboardingTroubleshoot: OnboardingTroubleshoot,
|
DateRangerPicker,
|
||||||
DateRangerPicker: DateRangerPicker,
|
DateTimePicker,
|
||||||
DateTimePicker: DateTimePicker,
|
OverviewChartDetails,
|
||||||
OverviewChartDetails: OverviewChartDetails,
|
AddIntegration,
|
||||||
AddIntegration: AddIntegration,
|
AddNotificationRule,
|
||||||
AddNotificationRule: AddNotificationRule,
|
OverviewFilters,
|
||||||
OverviewFilters: OverviewFilters,
|
CreateInvite,
|
||||||
CreateInvite: CreateInvite,
|
SelectBillingPlan,
|
||||||
SelectBillingPlan: SelectBillingPlan,
|
BillingSuccess,
|
||||||
BillingSuccess: BillingSuccess,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
@@ -83,7 +80,9 @@ export const {
|
|||||||
});
|
});
|
||||||
|
|
||||||
onPushModal('*', (open, props, name) => {
|
onPushModal('*', (open, props, name) => {
|
||||||
op.screenView(`modal:${name}`, props as Record<string, unknown>);
|
if (open) {
|
||||||
|
op.screenView(`modal:${name}`, props as Record<string, unknown>);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props);
|
export const showConfirm = (props: ConfirmProps) => pushModal('Confirm', props);
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
||||||
import { GlobeIcon, KeyIcon, UserIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
import { ModalContent, ModalHeader } from './Modal/Container';
|
|
||||||
|
|
||||||
export default function OnboardingTroubleshoot() {
|
|
||||||
return (
|
|
||||||
<ModalContent>
|
|
||||||
<ModalHeader
|
|
||||||
title="Troubleshoot"
|
|
||||||
text="Hmm, you have troubles? Well, let's solve them together."
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<Alert>
|
|
||||||
<UserIcon size={16} />
|
|
||||||
<AlertTitle>Wrong client ID</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
Make sure your <code>clientId</code> is correct
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Alert>
|
|
||||||
<GlobeIcon size={16} />
|
|
||||||
<AlertTitle>Wrong domain on web</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
For web apps its important that the domain is correctly configured.
|
|
||||||
We authenticate the requests based on the domain.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
<Alert>
|
|
||||||
<KeyIcon size={16} />
|
|
||||||
<AlertTitle>Wrong client secret</AlertTitle>
|
|
||||||
<AlertDescription>
|
|
||||||
For app and backend events it's important that you have correct{' '}
|
|
||||||
<code>clientId</code> and <code>clientSecret</code>
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
</div>
|
|
||||||
<p className="mt-4 ">
|
|
||||||
Still have issues? Join our{' '}
|
|
||||||
<a href="https://go.openpanel.dev/discord" className="underline">
|
|
||||||
discord channel
|
|
||||||
</a>{' '}
|
|
||||||
give us an email at{' '}
|
|
||||||
<a href="mailto:hello@openpanel.dev" className="underline">
|
|
||||||
hello@openpanel.dev
|
|
||||||
</a>{' '}
|
|
||||||
and we'll help you out.
|
|
||||||
</p>
|
|
||||||
</ModalContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { z } from 'zod';
|
||||||
import { Or } from '@/components/auth/or';
|
import { Or } from '@/components/auth/or';
|
||||||
import { SignInEmailForm } from '@/components/auth/sign-in-email-form';
|
import { SignInEmailForm } from '@/components/auth/sign-in-email-form';
|
||||||
import { SignInGithub } from '@/components/auth/sign-in-github';
|
import { SignInGithub } from '@/components/auth/sign-in-github';
|
||||||
import { SignInGoogle } from '@/components/auth/sign-in-google';
|
import { SignInGoogle } from '@/components/auth/sign-in-google';
|
||||||
import { LogoSquare } from '@/components/logo';
|
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
import { PAGE_TITLES, createTitle } from '@/utils/title';
|
import { useCookieStore } from '@/hooks/use-cookie-store';
|
||||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
import { createTitle, PAGE_TITLES } from '@/utils/title';
|
||||||
import { AlertCircle } from 'lucide-react';
|
|
||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_login/login')({
|
export const Route = createFileRoute('/_login/login')({
|
||||||
component: LoginPage,
|
component: LoginPage,
|
||||||
@@ -25,16 +25,20 @@ export const Route = createFileRoute('/_login/login')({
|
|||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const { error, correlationId } = Route.useSearch();
|
const { error, correlationId } = Route.useSearch();
|
||||||
|
const [lastProvider] = useCookieStore<null | string>(
|
||||||
|
'last-auth-provider',
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col gap-8 w-full text-left">
|
<div className="col w-full gap-8 text-left">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-3xl font-bold text-foreground mb-2">Sign in</h1>
|
<h1 className="mb-2 font-bold text-3xl text-foreground">Sign in</h1>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Don't have an account?{' '}
|
Don't have an account?{' '}
|
||||||
<a
|
<a
|
||||||
|
className="font-medium text-foreground underline"
|
||||||
href="/onboarding"
|
href="/onboarding"
|
||||||
className="underline font-medium text-foreground"
|
|
||||||
>
|
>
|
||||||
Create one today
|
Create one today
|
||||||
</a>
|
</a>
|
||||||
@@ -42,8 +46,8 @@ function LoginPage() {
|
|||||||
</div>
|
</div>
|
||||||
{error && (
|
{error && (
|
||||||
<Alert
|
<Alert
|
||||||
|
className="mb-6 border-destructive/20 bg-destructive/10 text-left"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
className="text-left bg-destructive/10 border-destructive/20 mb-6"
|
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertTitle>Error</AlertTitle>
|
<AlertTitle>Error</AlertTitle>
|
||||||
@@ -55,7 +59,7 @@ function LoginPage() {
|
|||||||
<p className="mt-2">
|
<p className="mt-2">
|
||||||
Contact us if you have any issues.{' '}
|
Contact us if you have any issues.{' '}
|
||||||
<a
|
<a
|
||||||
className="underline font-medium"
|
className="font-medium underline"
|
||||||
href={`mailto:hello@openpanel.dev?subject=Login%20Issue%20-%20Correlation%20ID%3A%20${correlationId}`}
|
href={`mailto:hello@openpanel.dev?subject=Login%20Issue%20-%20Correlation%20ID%3A%20${correlationId}`}
|
||||||
>
|
>
|
||||||
hello[at]openpanel.dev
|
hello[at]openpanel.dev
|
||||||
@@ -68,11 +72,11 @@ function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<SignInGoogle type="sign-in" />
|
<SignInGoogle isLastUsed={lastProvider === 'google'} type="sign-in" />
|
||||||
<SignInGithub type="sign-in" />
|
<SignInGithub isLastUsed={lastProvider === 'github'} type="sign-in" />
|
||||||
</div>
|
</div>
|
||||||
<Or />
|
<Or />
|
||||||
<SignInEmailForm />
|
<SignInEmailForm isLastUsed={lastProvider === 'email'} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,15 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { createFileRoute, redirect } from '@tanstack/react-router';
|
import { createFileRoute, redirect } from '@tanstack/react-router';
|
||||||
import { LockIcon, XIcon } from 'lucide-react';
|
import { CopyIcon, DownloadIcon, LockIcon, XIcon } from 'lucide-react';
|
||||||
import { ButtonContainer } from '@/components/button-container';
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
import CopyInput from '@/components/forms/copy-input';
|
|
||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
import ConnectApp from '@/components/onboarding/connect-app';
|
|
||||||
import ConnectBackend from '@/components/onboarding/connect-backend';
|
|
||||||
import ConnectWeb from '@/components/onboarding/connect-web';
|
import ConnectWeb from '@/components/onboarding/connect-web';
|
||||||
import { LinkButton } from '@/components/ui/button';
|
import Syntax from '@/components/syntax';
|
||||||
|
import { Button, LinkButton } from '@/components/ui/button';
|
||||||
import { useClientSecret } from '@/hooks/use-client-secret';
|
import { useClientSecret } from '@/hooks/use-client-secret';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { clipboard } from '@/utils/clipboard';
|
||||||
import { createEntityTitle, PAGE_TITLES } from '@/utils/title';
|
import { createEntityTitle, PAGE_TITLES } from '@/utils/title';
|
||||||
|
|
||||||
export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({
|
export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({
|
||||||
@@ -19,7 +18,7 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/connect')({
|
|||||||
{ title: createEntityTitle('Connect data', PAGE_TITLES.ONBOARDING) },
|
{ title: createEntityTitle('Connect data', PAGE_TITLES.ONBOARDING) },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!context.session?.session) {
|
if (!context.session?.session) {
|
||||||
throw redirect({ to: '/onboarding' });
|
throw redirect({ to: '/onboarding' });
|
||||||
}
|
}
|
||||||
@@ -54,27 +53,55 @@ function Component() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const credentials = `CLIENT_ID=${client.id}\nCLIENT_SECRET=${secret}`;
|
||||||
<div className="col gap-8 p-4">
|
const download = () => {
|
||||||
<div className="flex flex-col gap-4">
|
const blob = new Blob([credentials], { type: 'text/plain' });
|
||||||
<div className="flex items-center gap-2 font-bold text-xl capitalize">
|
const url = URL.createObjectURL(blob);
|
||||||
<LockIcon className="size-4" />
|
const a = document.createElement('a');
|
||||||
Credentials
|
a.href = url;
|
||||||
</div>
|
a.download = 'credentials.txt';
|
||||||
<CopyInput label="Client ID" value={client.id} />
|
a.click();
|
||||||
<CopyInput label="Secret" value={secret} />
|
};
|
||||||
</div>
|
|
||||||
<div className="-mx-4 h-px bg-muted" />
|
|
||||||
{project?.types?.map((type) => {
|
|
||||||
const Component = {
|
|
||||||
website: ConnectWeb,
|
|
||||||
app: ConnectApp,
|
|
||||||
backend: ConnectBackend,
|
|
||||||
}[type];
|
|
||||||
|
|
||||||
return <Component client={{ ...client, secret }} key={type} />;
|
return (
|
||||||
})}
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<ButtonContainer>
|
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
||||||
|
<div className="col gap-4 p-4">
|
||||||
|
<div className="col gap-2">
|
||||||
|
<div className="row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-2 font-bold text-xl capitalize">
|
||||||
|
<LockIcon className="size-4" />
|
||||||
|
Client credentials
|
||||||
|
</div>
|
||||||
|
<div className="row gap-2">
|
||||||
|
<Button
|
||||||
|
icon={CopyIcon}
|
||||||
|
onClick={() => clipboard(credentials)}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={DownloadIcon}
|
||||||
|
onClick={() => download()}
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Syntax
|
||||||
|
className="border"
|
||||||
|
code={`CLIENT_ID=${client.id}\nCLIENT_SECRET=${secret}`}
|
||||||
|
copyable={false}
|
||||||
|
language="bash"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="-mx-4 h-px bg-muted" />
|
||||||
|
<ConnectWeb client={{ ...client, secret }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ButtonContainer className="mt-0 flex-shrink-0 border-t bg-background p-4">
|
||||||
<div />
|
<div />
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className="min-w-28 self-start"
|
className="min-w-28 self-start"
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useEffect, useState } from 'react';
|
|||||||
import { ButtonContainer } from '@/components/button-container';
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
import { FullPageEmptyState } from '@/components/full-page-empty-state';
|
||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
import { CurlPreview } from '@/components/onboarding/curl-preview';
|
|
||||||
import VerifyListener from '@/components/onboarding/onboarding-verify-listener';
|
import VerifyListener from '@/components/onboarding/onboarding-verify-listener';
|
||||||
|
import { VerifyFaq } from '@/components/onboarding/verify-faq';
|
||||||
import { LinkButton } from '@/components/ui/button';
|
import { LinkButton } from '@/components/ui/button';
|
||||||
import { useTRPC } from '@/integrations/trpc/react';
|
import { useTRPC } from '@/integrations/trpc/react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -16,7 +16,7 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
|
|||||||
head: () => ({
|
head: () => ({
|
||||||
meta: [{ title: createEntityTitle('Verify', PAGE_TITLES.ONBOARDING) }],
|
meta: [{ title: createEntityTitle('Verify', PAGE_TITLES.ONBOARDING) }],
|
||||||
}),
|
}),
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!context.session?.session) {
|
if (!context.session?.session) {
|
||||||
throw redirect({ to: '/onboarding' });
|
throw redirect({ to: '/onboarding' });
|
||||||
}
|
}
|
||||||
@@ -61,20 +61,23 @@ function Component() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col gap-8 p-4">
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
<VerifyListener
|
<div className="scrollbar-thin flex-1 overflow-y-auto">
|
||||||
client={client}
|
<div className="col gap-8 p-4">
|
||||||
events={events?.data ?? []}
|
<VerifyListener
|
||||||
onVerified={() => {
|
client={client}
|
||||||
refetch();
|
events={events?.data ?? []}
|
||||||
setIsVerified(true);
|
onVerified={() => {
|
||||||
}}
|
refetch();
|
||||||
project={project}
|
setIsVerified(true);
|
||||||
/>
|
}}
|
||||||
|
project={project}
|
||||||
|
/>
|
||||||
|
|
||||||
<CurlPreview project={project} />
|
<VerifyFaq project={project} />
|
||||||
|
</div>
|
||||||
<ButtonContainer>
|
</div>
|
||||||
|
<ButtonContainer className="mt-0 flex-shrink-0 border-t bg-background p-4">
|
||||||
<LinkButton
|
<LinkButton
|
||||||
className="min-w-28 self-start"
|
className="min-w-28 self-start"
|
||||||
href={`/onboarding/${project.id}/connect`}
|
href={`/onboarding/${project.id}/connect`}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
ServerIcon,
|
ServerIcon,
|
||||||
SmartphoneIcon,
|
SmartphoneIcon,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Controller,
|
Controller,
|
||||||
type SubmitHandler,
|
type SubmitHandler,
|
||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import AnimateHeight from '@/components/animate-height';
|
import AnimateHeight from '@/components/animate-height';
|
||||||
import { ButtonContainer } from '@/components/button-container';
|
import { ButtonContainer } from '@/components/button-container';
|
||||||
import { CheckboxItem } from '@/components/forms/checkbox-item';
|
|
||||||
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
import { InputWithLabel, WithLabel } from '@/components/forms/input-with-label';
|
||||||
import TagInput from '@/components/forms/tag-input';
|
import TagInput from '@/components/forms/tag-input';
|
||||||
import FullPageLoadingState from '@/components/full-page-loading-state';
|
import FullPageLoadingState from '@/components/full-page-loading-state';
|
||||||
@@ -27,6 +26,7 @@ import { Combobox } from '@/components/ui/combobox';
|
|||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { useClientSecret } from '@/hooks/use-client-secret';
|
import { useClientSecret } from '@/hooks/use-client-secret';
|
||||||
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
import { handleError, useTRPC } from '@/integrations/trpc/react';
|
||||||
|
import { cn } from '@/utils/cn';
|
||||||
|
|
||||||
const validateSearch = z.object({
|
const validateSearch = z.object({
|
||||||
inviteId: z.string().optional(),
|
inviteId: z.string().optional(),
|
||||||
@@ -34,7 +34,7 @@ const validateSearch = z.object({
|
|||||||
export const Route = createFileRoute('/_steps/onboarding/project')({
|
export const Route = createFileRoute('/_steps/onboarding/project')({
|
||||||
component: Component,
|
component: Component,
|
||||||
validateSearch,
|
validateSearch,
|
||||||
beforeLoad: async ({ context }) => {
|
beforeLoad: ({ context }) => {
|
||||||
if (!context.session?.session) {
|
if (!context.session?.session) {
|
||||||
throw redirect({ to: '/onboarding' });
|
throw redirect({ to: '/onboarding' });
|
||||||
}
|
}
|
||||||
@@ -105,10 +105,18 @@ function Component() {
|
|||||||
control: form.control,
|
control: form.control,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const domain = useWatch({
|
||||||
|
name: 'domain',
|
||||||
|
control: form.control,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showCorsInput, setShowCorsInput] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isWebsite) {
|
if (!isWebsite) {
|
||||||
form.setValue('domain', null);
|
form.setValue('domain', null);
|
||||||
form.setValue('cors', []);
|
form.setValue('cors', []);
|
||||||
|
setShowCorsInput(false);
|
||||||
}
|
}
|
||||||
}, [isWebsite, form]);
|
}, [isWebsite, form]);
|
||||||
|
|
||||||
@@ -121,8 +129,11 @@ function Component() {
|
|||||||
}, [isWebsite, isApp, isBackend]);
|
}, [isWebsite, isApp, isBackend]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
<form
|
||||||
<div className="p-4">
|
className="flex min-h-0 flex-1 flex-col"
|
||||||
|
onSubmit={form.handleSubmit(onSubmit)}
|
||||||
|
>
|
||||||
|
<div className="scrollbar-thin flex-1 overflow-y-auto p-4">
|
||||||
<div className="grid gap-4 md:grid-cols-2">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
{organizations.length > 0 ? (
|
{organizations.length > 0 ? (
|
||||||
<Controller
|
<Controller
|
||||||
@@ -174,6 +185,7 @@ function Component() {
|
|||||||
}))}
|
}))}
|
||||||
onChange={field.onChange}
|
onChange={field.onChange}
|
||||||
placeholder="Select timezone"
|
placeholder="Select timezone"
|
||||||
|
searchable
|
||||||
value={field.value}
|
value={field.value}
|
||||||
/>
|
/>
|
||||||
</WithLabel>
|
</WithLabel>
|
||||||
@@ -183,115 +195,142 @@ function Component() {
|
|||||||
)}
|
)}
|
||||||
<InputWithLabel
|
<InputWithLabel
|
||||||
error={form.formState.errors.project?.message}
|
error={form.formState.errors.project?.message}
|
||||||
label="Project name"
|
label="Your first project name"
|
||||||
placeholder="Eg. The Music App"
|
placeholder="Eg. The Music App"
|
||||||
{...form.register('project')}
|
{...form.register('project')}
|
||||||
className="col-span-2"
|
className="col-span-2"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 flex flex-col divide-y">
|
<div className="mt-4">
|
||||||
<Controller
|
<Label className="mb-2">What are you tracking?</Label>
|
||||||
control={form.control}
|
<div className="grid grid-cols-3 gap-3">
|
||||||
name="website"
|
{[
|
||||||
render={({ field }) => (
|
{
|
||||||
<CheckboxItem
|
key: 'website' as const,
|
||||||
description="Track events and conversion for your website"
|
label: 'Website',
|
||||||
disabled={isApp}
|
Icon: MonitorIcon,
|
||||||
error={form.formState.errors.website?.message}
|
active: isWebsite,
|
||||||
Icon={MonitorIcon}
|
},
|
||||||
label="Website"
|
{
|
||||||
{...field}
|
key: 'app' as const,
|
||||||
|
label: 'App',
|
||||||
|
Icon: SmartphoneIcon,
|
||||||
|
active: isApp,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'backend' as const,
|
||||||
|
label: 'Backend / API',
|
||||||
|
Icon: ServerIcon,
|
||||||
|
active: isBackend,
|
||||||
|
},
|
||||||
|
].map(({ key, label, Icon, active }) => (
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-2 rounded-lg border-2 p-4 transition-colors',
|
||||||
|
active
|
||||||
|
? 'border-primary bg-primary/5 text-primary'
|
||||||
|
: 'border-border text-muted-foreground hover:border-primary/40'
|
||||||
|
)}
|
||||||
|
key={key}
|
||||||
|
onClick={() => {
|
||||||
|
form.setValue(key, !active, { shouldValidate: true });
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<AnimateHeight open={isWebsite && !isApp}>
|
<Icon size={24} />
|
||||||
<div className="p-4 pl-14">
|
<span className="font-medium text-sm">{label}</span>
|
||||||
<InputWithLabel
|
</button>
|
||||||
label="Domain"
|
))}
|
||||||
placeholder="Your website address"
|
</div>
|
||||||
{...form.register('domain')}
|
{(form.formState.errors.website?.message ||
|
||||||
className="mb-4"
|
form.formState.errors.app?.message ||
|
||||||
error={form.formState.errors.domain?.message}
|
form.formState.errors.backend?.message) && (
|
||||||
onBlur={(e) => {
|
<p className="mt-2 text-destructive text-sm">
|
||||||
const value = e.target.value.trim();
|
At least one type must be selected
|
||||||
if (
|
</p>
|
||||||
value.includes('.') &&
|
)}
|
||||||
form.getValues().cors.length === 0 &&
|
<AnimateHeight open={isWebsite}>
|
||||||
!form.formState.errors.domain
|
<div className="mt-4">
|
||||||
) {
|
<InputWithLabel
|
||||||
form.setValue('cors', [value]);
|
label="Domain"
|
||||||
}
|
placeholder="example.com"
|
||||||
}}
|
{...form.register('domain')}
|
||||||
/>
|
error={form.formState.errors.domain?.message}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
if (!raw) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
<Controller
|
const hasProtocol =
|
||||||
control={form.control}
|
raw.startsWith('http://') || raw.startsWith('https://');
|
||||||
name="cors"
|
const value = hasProtocol ? raw : `https://${raw}`;
|
||||||
render={({ field }) => (
|
|
||||||
<WithLabel label="Allowed domains">
|
form.setValue('domain', value, { shouldValidate: true });
|
||||||
<TagInput
|
if (form.getValues().cors.length === 0) {
|
||||||
{...field}
|
form.setValue('cors', [value]);
|
||||||
error={form.formState.errors.cors?.message}
|
}
|
||||||
onChange={(newValue) => {
|
}}
|
||||||
field.onChange(
|
|
||||||
newValue.map((item) => {
|
|
||||||
const trimmed = item.trim();
|
|
||||||
if (
|
|
||||||
trimmed.startsWith('http://') ||
|
|
||||||
trimmed.startsWith('https://') ||
|
|
||||||
trimmed === '*'
|
|
||||||
) {
|
|
||||||
return trimmed;
|
|
||||||
}
|
|
||||||
return `https://${trimmed}`;
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
placeholder="Accept events from these domains"
|
|
||||||
renderTag={(tag) =>
|
|
||||||
tag === '*'
|
|
||||||
? 'Accept events from any domains'
|
|
||||||
: tag
|
|
||||||
}
|
|
||||||
value={field.value ?? []}
|
|
||||||
/>
|
|
||||||
</WithLabel>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</AnimateHeight>
|
|
||||||
</CheckboxItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<Controller
|
|
||||||
control={form.control}
|
|
||||||
name="app"
|
|
||||||
render={({ field }) => (
|
|
||||||
<CheckboxItem
|
|
||||||
description="Track events and conversion for your app"
|
|
||||||
disabled={isWebsite}
|
|
||||||
error={form.formState.errors.app?.message}
|
|
||||||
Icon={SmartphoneIcon}
|
|
||||||
label="App"
|
|
||||||
{...field}
|
|
||||||
/>
|
/>
|
||||||
)}
|
{domain && (
|
||||||
/>
|
<>
|
||||||
<Controller
|
<button
|
||||||
control={form.control}
|
className="mt-2 text-muted-foreground text-sm hover:text-foreground"
|
||||||
name="backend"
|
onClick={() => setShowCorsInput((open) => !open)}
|
||||||
render={({ field }) => (
|
type="button"
|
||||||
<CheckboxItem
|
>
|
||||||
description="Track events and conversion for your backend / API"
|
All events from{' '}
|
||||||
error={form.formState.errors.backend?.message}
|
<span className="font-medium text-foreground">
|
||||||
Icon={ServerIcon}
|
{domain}
|
||||||
label="Backend / API"
|
</span>{' '}
|
||||||
{...field}
|
will be allowed. Do you want to allow any other?
|
||||||
/>
|
</button>
|
||||||
)}
|
<AnimateHeight open={showCorsInput}>
|
||||||
/>
|
<div className="mt-3">
|
||||||
|
<Controller
|
||||||
|
control={form.control}
|
||||||
|
name="cors"
|
||||||
|
render={({ field }) => (
|
||||||
|
<WithLabel label="Allowed domains">
|
||||||
|
<TagInput
|
||||||
|
{...field}
|
||||||
|
error={form.formState.errors.cors?.message}
|
||||||
|
onChange={(newValue: string[]) => {
|
||||||
|
field.onChange(
|
||||||
|
newValue.map((item: string) => {
|
||||||
|
const trimmed = item.trim();
|
||||||
|
if (
|
||||||
|
trimmed.startsWith('http://') ||
|
||||||
|
trimmed.startsWith('https://') ||
|
||||||
|
trimmed === '*'
|
||||||
|
) {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
return `https://${trimmed}`;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
placeholder="Accept events from these domains"
|
||||||
|
renderTag={(tag: string) =>
|
||||||
|
tag === '*'
|
||||||
|
? 'Accept events from any domains'
|
||||||
|
: tag
|
||||||
|
}
|
||||||
|
value={field.value ?? []}
|
||||||
|
/>
|
||||||
|
</WithLabel>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</AnimateHeight>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</AnimateHeight>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ButtonContainer className="border-t p-4">
|
<ButtonContainer className="mt-0 flex-shrink-0 border-t bg-background p-4">
|
||||||
<div />
|
<div />
|
||||||
<Button
|
<Button
|
||||||
className="min-w-28 self-start"
|
className="min-w-28 self-start"
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
import { OnboardingLeftPanel } from '@/components/onboarding-left-panel';
|
import { createFileRoute, Outlet, useMatchRoute } from '@tanstack/react-router';
|
||||||
import { SkeletonDashboard } from '@/components/skeleton-dashboard';
|
import { SkeletonDashboard } from '@/components/skeleton-dashboard';
|
||||||
import { cn } from '@/utils/cn';
|
import { cn } from '@/utils/cn';
|
||||||
import { PAGE_TITLES, createEntityTitle } from '@/utils/title';
|
import { createEntityTitle, PAGE_TITLES } from '@/utils/title';
|
||||||
import {
|
|
||||||
Outlet,
|
|
||||||
createFileRoute,
|
|
||||||
redirect,
|
|
||||||
useLocation,
|
|
||||||
useMatchRoute,
|
|
||||||
} from '@tanstack/react-router';
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_steps')({
|
export const Route = createFileRoute('/_steps')({
|
||||||
component: OnboardingLayout,
|
component: OnboardingLayout,
|
||||||
@@ -19,13 +12,18 @@ export const Route = createFileRoute('/_steps')({
|
|||||||
|
|
||||||
function OnboardingLayout() {
|
function OnboardingLayout() {
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-screen pt-32 pb-8">
|
<div className="relative flex min-h-screen items-center justify-center p-4">
|
||||||
<div className="fixed inset-0 hidden md:block">
|
<div className="fixed inset-0 hidden md:block">
|
||||||
<SkeletonDashboard />
|
<SkeletonDashboard />
|
||||||
|
<div className="fixed inset-0 z-10 bg-def-100/50" />
|
||||||
</div>
|
</div>
|
||||||
<div className="relative z-10 border bg-background rounded-lg shadow-xl shadow-muted/50 max-w-xl mx-auto">
|
<div className="relative z-10 flex max-h-[calc(100vh-2rem)] w-full max-w-xl flex-col overflow-hidden rounded-lg border bg-background shadow-muted/50 shadow-xl">
|
||||||
<Progress />
|
<div className="sticky top-0 z-10 flex-shrink-0 border-b bg-background">
|
||||||
<Outlet />
|
<Progress />
|
||||||
|
</div>
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -53,18 +51,18 @@ function Progress() {
|
|||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
from: step.match,
|
from: step.match,
|
||||||
fuzzy: false,
|
fuzzy: false,
|
||||||
}),
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="row gap-4 p-4 border-b justify-between items-center flex-1 w-full">
|
<div className="row w-full flex-shrink-0 items-center justify-between gap-4 p-4">
|
||||||
<div className="font-bold">{currentStep?.name ?? 'Onboarding'}</div>
|
<div className="font-bold">{currentStep?.name ?? 'Onboarding'}</div>
|
||||||
<div className="row gap-4">
|
<div className="row gap-4">
|
||||||
{steps.map((step) => (
|
{steps.map((step) => (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-10 h-2 rounded-full bg-muted',
|
'h-2 w-10 rounded-full bg-muted',
|
||||||
currentStep === step && 'w-20 bg-primary',
|
currentStep === step && 'w-20 bg-primary'
|
||||||
)}
|
)}
|
||||||
key={step.match}
|
key={step.match}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -277,6 +277,30 @@ button {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Thin scrollbar, visible on hover - "the small little nice thingy" */
|
||||||
|
.scrollbar-thin {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: transparent transparent;
|
||||||
|
}
|
||||||
|
.scrollbar-thin:hover {
|
||||||
|
scrollbar-color: var(--color-border) transparent;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
border-radius: 9999px;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
}
|
||||||
|
.scrollbar-thin:hover::-webkit-scrollbar-thumb,
|
||||||
|
.scrollbar-thin:active::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
/* Hide scrollbar for IE, Edge and Firefox */
|
/* Hide scrollbar for IE, Edge and Firefox */
|
||||||
.hide-scrollbar {
|
.hide-scrollbar {
|
||||||
-ms-overflow-style: none; /* IE and Edge */
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import {
|
|||||||
getUserAccount,
|
getUserAccount,
|
||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
import { sendEmail } from '@openpanel/email';
|
import { sendEmail } from '@openpanel/email';
|
||||||
import { deleteCache } from '@openpanel/redis';
|
|
||||||
import {
|
import {
|
||||||
zRequestResetPassword,
|
zRequestResetPassword,
|
||||||
zResetPassword,
|
zResetPassword,
|
||||||
@@ -81,7 +80,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
|
.input(z.object({ provider: zProvider, inviteId: z.string().nullish() }))
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const isRegistrationAllowed = await getIsRegistrationAllowed(
|
const isRegistrationAllowed = await getIsRegistrationAllowed(
|
||||||
input.inviteId,
|
input.inviteId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isRegistrationAllowed) {
|
if (!isRegistrationAllowed) {
|
||||||
@@ -137,7 +136,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
.input(zSignUpEmail)
|
.input(zSignUpEmail)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const isRegistrationAllowed = await getIsRegistrationAllowed(
|
const isRegistrationAllowed = await getIsRegistrationAllowed(
|
||||||
input.inviteId,
|
input.inviteId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isRegistrationAllowed) {
|
if (!isRegistrationAllowed) {
|
||||||
@@ -187,7 +186,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
rateLimitMiddleware({
|
rateLimitMiddleware({
|
||||||
max: 3,
|
max: 3,
|
||||||
windowMs: 30_000,
|
windowMs: 30_000,
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.input(zSignInEmail)
|
.input(zSignInEmail)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
@@ -210,7 +209,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
if (user.account.password?.startsWith('$argon2')) {
|
if (user.account.password?.startsWith('$argon2')) {
|
||||||
const validPassword = await verifyPasswordHash(
|
const validPassword = await verifyPasswordHash(
|
||||||
user.account.password ?? '',
|
user.account.password ?? '',
|
||||||
password,
|
password
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
@@ -218,7 +217,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw TRPCAccessError(
|
throw TRPCAccessError(
|
||||||
'Reset your password, old password has expired',
|
'Reset your password, old password has expired'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +225,11 @@ export const authRouter = createTRPCRouter({
|
|||||||
const token = generateSessionToken();
|
const token = generateSessionToken();
|
||||||
const session = await createSession(token, user.id);
|
const session = await createSession(token, user.id);
|
||||||
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
|
setSessionTokenCookie(ctx.setCookie, token, session.expiresAt);
|
||||||
|
ctx.setCookie('last-auth-provider', 'email', {
|
||||||
|
maxAge: 60 * 60 * 24 * 365,
|
||||||
|
path: '/',
|
||||||
|
sameSite: 'lax',
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
type: 'email',
|
type: 'email',
|
||||||
};
|
};
|
||||||
@@ -237,7 +241,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
rateLimitMiddleware({
|
rateLimitMiddleware({
|
||||||
max: 3,
|
max: 3,
|
||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
const { token, password } = input;
|
const { token, password } = input;
|
||||||
@@ -275,7 +279,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
rateLimitMiddleware({
|
rateLimitMiddleware({
|
||||||
max: 3,
|
max: 3,
|
||||||
windowMs: 60_000,
|
windowMs: 60_000,
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.input(zRequestResetPassword)
|
.input(zRequestResetPassword)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
@@ -324,7 +328,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
extendSession: publicProcedure.mutation(async ({ ctx }) => {
|
extendSession: publicProcedure.mutation(async ({ ctx }) => {
|
||||||
if (!ctx.session.session || !ctx.cookies.session) {
|
if (!(ctx.session.session && ctx.cookies.session)) {
|
||||||
return { extended: false };
|
return { extended: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -348,7 +352,7 @@ export const authRouter = createTRPCRouter({
|
|||||||
rateLimitMiddleware({
|
rateLimitMiddleware({
|
||||||
max: 3,
|
max: 3,
|
||||||
windowMs: 30_000,
|
windowMs: 30_000,
|
||||||
}),
|
})
|
||||||
)
|
)
|
||||||
.input(zSignInShare)
|
.input(zSignInShare)
|
||||||
.mutation(async ({ input, ctx }) => {
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user