add seventy seven
This commit is contained in:
@@ -29,6 +29,9 @@ ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
|
|||||||
ARG CLERK_SECRET_KEY
|
ARG CLERK_SECRET_KEY
|
||||||
ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY
|
ENV CLERK_SECRET_KEY=$CLERK_SECRET_KEY
|
||||||
|
|
||||||
|
ARG SEVENTY_SEVEN_API_KEY
|
||||||
|
ENV SEVENTY_SEVEN_API_KEY=$SEVENTY_SEVEN_API_KEY
|
||||||
|
|
||||||
ENV PNPM_HOME="/pnpm"
|
ENV PNPM_HOME="/pnpm"
|
||||||
|
|
||||||
ENV PATH="$PNPM_HOME:$PATH"
|
ENV PATH="$PNPM_HOME:$PATH"
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
|
|||||||
if (hasProjectId) {
|
if (hasProjectId) {
|
||||||
user?.update({
|
user?.update({
|
||||||
unsafeMetadata: {
|
unsafeMetadata: {
|
||||||
|
...user.unsafeMetadata,
|
||||||
projectId: params.projectId,
|
projectId: params.projectId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
} from '@openpanel/db';
|
} from '@openpanel/db';
|
||||||
|
|
||||||
import { LayoutSidebar } from './layout-sidebar';
|
import { LayoutSidebar } from './layout-sidebar';
|
||||||
|
import SideEffects from './side-effects';
|
||||||
|
|
||||||
interface AppLayoutProps {
|
interface AppLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@@ -48,6 +49,7 @@ export default async function AppLayout({
|
|||||||
{...{ organizationSlug, projectId, organizations, dashboards }}
|
{...{ organizationSlug, projectId, organizations, dashboards }}
|
||||||
/>
|
/>
|
||||||
<div className="transition-all lg:pl-72">{children}</div>
|
<div className="transition-all lg:pl-72">{children}</div>
|
||||||
|
<SideEffects />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { pushModal, useOnPushModal } from '@/modals';
|
||||||
|
import { useUser } from '@clerk/nextjs';
|
||||||
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
|
import { trackEvent } from '@openpanel/nextjs';
|
||||||
|
|
||||||
|
export default function SideEffects() {
|
||||||
|
const { user } = useUser();
|
||||||
|
const accountAgeInDays = differenceInDays(
|
||||||
|
new Date(),
|
||||||
|
user?.createdAt || new Date()
|
||||||
|
);
|
||||||
|
useOnPushModal('Testimonial', (open) => {
|
||||||
|
if (!open) {
|
||||||
|
user?.update({
|
||||||
|
unsafeMetadata: {
|
||||||
|
...user.unsafeMetadata,
|
||||||
|
testimonial: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const showTestimonial =
|
||||||
|
user && !user.unsafeMetadata.testimonial && accountAgeInDays > 7;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (showTestimonial) {
|
||||||
|
pushModal('Testimonial');
|
||||||
|
trackEvent('testimonials_shown');
|
||||||
|
}
|
||||||
|
}, [showTestimonial]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import makeStore from '@/redux';
|
|||||||
import { api } from '@/trpc/client';
|
import { api } from '@/trpc/client';
|
||||||
import { ClerkProvider, useAuth } from '@clerk/nextjs';
|
import { ClerkProvider, useAuth } from '@clerk/nextjs';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { httpLink, unstable_httpBatchStreamLink } from '@trpc/client';
|
import { unstable_httpBatchStreamLink } from '@trpc/client';
|
||||||
import { ThemeProvider } from 'next-themes';
|
import { ThemeProvider } from 'next-themes';
|
||||||
import { Provider as ReduxProvider } from 'react-redux';
|
import { Provider as ReduxProvider } from 'react-redux';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
|
|||||||
24
apps/dashboard/src/components/ui/textarea.tsx
Normal file
24
apps/dashboard/src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import * as React from "react"
|
||||||
|
|
||||||
|
import { cn } from "@/utils/cn"
|
||||||
|
|
||||||
|
export interface TextareaProps
|
||||||
|
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
|
||||||
|
|
||||||
|
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<textarea
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Textarea.displayName = "Textarea"
|
||||||
|
|
||||||
|
export { Textarea }
|
||||||
78
apps/dashboard/src/modals/Testimonial.tsx
Normal file
78
apps/dashboard/src/modals/Testimonial.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { useAppParams } from '@/hooks/useAppParams';
|
||||||
|
import { api } from '@/trpc/client';
|
||||||
|
import { useUser } from '@clerk/nextjs';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { trackEvent } from '@openpanel/nextjs';
|
||||||
|
|
||||||
|
import { popModal } from '.';
|
||||||
|
import { ModalContent } from './Modal/Container';
|
||||||
|
|
||||||
|
const validator = z.object({
|
||||||
|
body: z.string().min(3),
|
||||||
|
});
|
||||||
|
|
||||||
|
type IForm = z.infer<typeof validator>;
|
||||||
|
|
||||||
|
const Testimonial = () => {
|
||||||
|
const mutation = api.ticket.create.useMutation();
|
||||||
|
const params = useAppParams();
|
||||||
|
const form = useForm<IForm>({
|
||||||
|
resolver: zodResolver(validator),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<ModalContent className="p-0">
|
||||||
|
<div className="w-full rounded-t-lg border-b border-border bg-secondary p-4">
|
||||||
|
<h1 className="mb-2 text-2xl font-bold">Review time 🫶</h1>
|
||||||
|
<p className="mb-2">
|
||||||
|
Thank you so much for using Openpanel — it truly means a great deal to
|
||||||
|
me! If you're enjoying your experience, I'd be thrilled if
|
||||||
|
you could leave a quick review. 😇
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you have any feedback or suggestions, I'd love to hear them as
|
||||||
|
well! 🚀
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
className="p-4"
|
||||||
|
onSubmit={form.handleSubmit(async ({ body }) => {
|
||||||
|
try {
|
||||||
|
await mutation.mutateAsync({
|
||||||
|
subject: 'New testimonial',
|
||||||
|
body,
|
||||||
|
meta: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
toast.success('Thanks for your feedback 🚀');
|
||||||
|
trackEvent('testimonials_sent');
|
||||||
|
popModal();
|
||||||
|
} catch (e) {
|
||||||
|
toast.error('Something went wrong. Please try again later.');
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
placeholder="Type your review here."
|
||||||
|
{...form.register('body')}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex justify-between gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => popModal()}>
|
||||||
|
Maybe later
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={form.formState.isSubmitting}>
|
||||||
|
Send it
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Testimonial;
|
||||||
@@ -65,6 +65,9 @@ const modals = {
|
|||||||
OverviewChartDetails: dynamic(() => import('./OverviewChartDetails'), {
|
OverviewChartDetails: dynamic(() => import('./OverviewChartDetails'), {
|
||||||
loading: Loading,
|
loading: Loading,
|
||||||
}),
|
}),
|
||||||
|
Testimonial: dynamic(() => import('./Testimonial'), {
|
||||||
|
loading: Loading,
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"@openpanel/constants": "workspace:*",
|
"@openpanel/constants": "workspace:*",
|
||||||
"@openpanel/db": "workspace:*",
|
"@openpanel/db": "workspace:*",
|
||||||
"@openpanel/validation": "workspace:*",
|
"@openpanel/validation": "workspace:*",
|
||||||
|
"@seventy-seven/sdk": "0.0.0-beta.2",
|
||||||
"@trpc/server": "^10.45.1",
|
"@trpc/server": "^10.45.1",
|
||||||
"date-fns": "^3.3.1",
|
"date-fns": "^3.3.1",
|
||||||
"mathjs": "^12.3.2",
|
"mathjs": "^12.3.2",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { projectRouter } from './routers/project';
|
|||||||
import { referenceRouter } from './routers/reference';
|
import { referenceRouter } from './routers/reference';
|
||||||
import { reportRouter } from './routers/report';
|
import { reportRouter } from './routers/report';
|
||||||
import { shareRouter } from './routers/share';
|
import { shareRouter } from './routers/share';
|
||||||
|
import { ticketRouter } from './routers/ticket';
|
||||||
import { userRouter } from './routers/user';
|
import { userRouter } from './routers/user';
|
||||||
import { createTRPCRouter } from './trpc';
|
import { createTRPCRouter } from './trpc';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ export const appRouter = createTRPCRouter({
|
|||||||
share: shareRouter,
|
share: shareRouter,
|
||||||
onboarding: onboardingRouter,
|
onboarding: onboardingRouter,
|
||||||
reference: referenceRouter,
|
reference: referenceRouter,
|
||||||
|
ticket: ticketRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
|
|||||||
34
packages/trpc/src/routers/ticket.ts
Normal file
34
packages/trpc/src/routers/ticket.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { clerkClient } from '@clerk/fastify';
|
||||||
|
import { SeventySevenClient } from '@seventy-seven/sdk';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { createTRPCRouter, protectedProcedure } from '../trpc';
|
||||||
|
|
||||||
|
const API_KEY = process.env.SEVENTY_SEVEN_API_KEY!;
|
||||||
|
const client = new SeventySevenClient(API_KEY);
|
||||||
|
|
||||||
|
export const ticketRouter = createTRPCRouter({
|
||||||
|
create: protectedProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
subject: z.string(),
|
||||||
|
body: z.string(),
|
||||||
|
meta: z.record(z.string(), z.unknown()),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (!API_KEY) {
|
||||||
|
throw new Error('Ticket system not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await clerkClient.users.getUser(ctx.session.userId);
|
||||||
|
|
||||||
|
return client.createTicket({
|
||||||
|
subject: input.subject,
|
||||||
|
body: input.body,
|
||||||
|
meta: input.meta,
|
||||||
|
senderEmail: user.primaryEmailAddress?.emailAddress || 'none',
|
||||||
|
senderFullName: user.fullName || 'none',
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
7
pnpm-lock.yaml
generated
7
pnpm-lock.yaml
generated
@@ -1243,6 +1243,9 @@ importers:
|
|||||||
'@openpanel/validation':
|
'@openpanel/validation':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../validation
|
version: link:../validation
|
||||||
|
'@seventy-seven/sdk':
|
||||||
|
specifier: 0.0.0-beta.2
|
||||||
|
version: 0.0.0-beta.2
|
||||||
'@trpc/server':
|
'@trpc/server':
|
||||||
specifier: ^10.45.1
|
specifier: ^10.45.1
|
||||||
version: 10.45.1
|
version: 10.45.1
|
||||||
@@ -6979,6 +6982,10 @@ packages:
|
|||||||
join-component: 1.1.0
|
join-component: 1.1.0
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
|
/@seventy-seven/sdk@0.0.0-beta.2:
|
||||||
|
resolution: {integrity: sha512-aqRk1zvfEiq0yn20UwGvcQ1PGOLrGo3fSKZmhgKOesh8HFnimrduJ4uGJjpJUSmvvKafL/rC7wXCrMuwtSiYQg==}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/@sideway/address@4.1.5:
|
/@sideway/address@4.1.5:
|
||||||
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
|
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user