diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index f7ddae84..8158360c 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -29,6 +29,9 @@ ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ARG 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 PATH="$PNPM_HOME:$PATH"
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx
index 4c84d9fe..222c9be3 100644
--- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx
+++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout-menu.tsx
@@ -74,6 +74,7 @@ export default function LayoutMenu({ dashboards }: LayoutMenuProps) {
if (hasProjectId) {
user?.update({
unsafeMetadata: {
+ ...user.unsafeMetadata,
projectId: params.projectId,
},
});
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx
index b49720df..8d825e96 100644
--- a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx
+++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/layout.tsx
@@ -7,6 +7,7 @@ import {
} from '@openpanel/db';
import { LayoutSidebar } from './layout-sidebar';
+import SideEffects from './side-effects';
interface AppLayoutProps {
children: React.ReactNode;
@@ -48,6 +49,7 @@ export default async function AppLayout({
{...{ organizationSlug, projectId, organizations, dashboards }}
/>
{children}
+
);
}
diff --git a/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx
new file mode 100644
index 00000000..3f69ff27
--- /dev/null
+++ b/apps/dashboard/src/app/(app)/[organizationSlug]/[projectId]/side-effects.tsx
@@ -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;
+}
diff --git a/apps/dashboard/src/app/providers.tsx b/apps/dashboard/src/app/providers.tsx
index 717dffa2..84ed1d35 100644
--- a/apps/dashboard/src/app/providers.tsx
+++ b/apps/dashboard/src/app/providers.tsx
@@ -8,7 +8,7 @@ import makeStore from '@/redux';
import { api } from '@/trpc/client';
import { ClerkProvider, useAuth } from '@clerk/nextjs';
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 { Provider as ReduxProvider } from 'react-redux';
import { Toaster } from 'sonner';
diff --git a/apps/dashboard/src/components/ui/textarea.tsx b/apps/dashboard/src/components/ui/textarea.tsx
new file mode 100644
index 00000000..abe00e07
--- /dev/null
+++ b/apps/dashboard/src/components/ui/textarea.tsx
@@ -0,0 +1,24 @@
+import * as React from "react"
+
+import { cn } from "@/utils/cn"
+
+export interface TextareaProps
+ extends React.TextareaHTMLAttributes {}
+
+const Textarea = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ return (
+
+ )
+ }
+)
+Textarea.displayName = "Textarea"
+
+export { Textarea }
diff --git a/apps/dashboard/src/modals/Testimonial.tsx b/apps/dashboard/src/modals/Testimonial.tsx
new file mode 100644
index 00000000..9531e702
--- /dev/null
+++ b/apps/dashboard/src/modals/Testimonial.tsx
@@ -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;
+
+const Testimonial = () => {
+ const mutation = api.ticket.create.useMutation();
+ const params = useAppParams();
+ const form = useForm({
+ resolver: zodResolver(validator),
+ });
+ return (
+
+
+
Review time 🫶
+
+ 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. 😇
+
+
+ If you have any feedback or suggestions, I'd love to hear them as
+ well! 🚀
+
+
+
+
+ );
+};
+
+export default Testimonial;
diff --git a/apps/dashboard/src/modals/index.tsx b/apps/dashboard/src/modals/index.tsx
index d2a97eca..c6421bdf 100644
--- a/apps/dashboard/src/modals/index.tsx
+++ b/apps/dashboard/src/modals/index.tsx
@@ -65,6 +65,9 @@ const modals = {
OverviewChartDetails: dynamic(() => import('./OverviewChartDetails'), {
loading: Loading,
}),
+ Testimonial: dynamic(() => import('./Testimonial'), {
+ loading: Loading,
+ }),
};
export const {
diff --git a/packages/trpc/package.json b/packages/trpc/package.json
index e14ee1c9..c48234c1 100644
--- a/packages/trpc/package.json
+++ b/packages/trpc/package.json
@@ -13,6 +13,7 @@
"@openpanel/constants": "workspace:*",
"@openpanel/db": "workspace:*",
"@openpanel/validation": "workspace:*",
+ "@seventy-seven/sdk": "0.0.0-beta.2",
"@trpc/server": "^10.45.1",
"date-fns": "^3.3.1",
"mathjs": "^12.3.2",
diff --git a/packages/trpc/src/root.ts b/packages/trpc/src/root.ts
index fbd3af14..6f6e1212 100644
--- a/packages/trpc/src/root.ts
+++ b/packages/trpc/src/root.ts
@@ -9,6 +9,7 @@ import { projectRouter } from './routers/project';
import { referenceRouter } from './routers/reference';
import { reportRouter } from './routers/report';
import { shareRouter } from './routers/share';
+import { ticketRouter } from './routers/ticket';
import { userRouter } from './routers/user';
import { createTRPCRouter } from './trpc';
@@ -30,6 +31,7 @@ export const appRouter = createTRPCRouter({
share: shareRouter,
onboarding: onboardingRouter,
reference: referenceRouter,
+ ticket: ticketRouter,
});
// export type definition of API
diff --git a/packages/trpc/src/routers/ticket.ts b/packages/trpc/src/routers/ticket.ts
new file mode 100644
index 00000000..a65ada58
--- /dev/null
+++ b/packages/trpc/src/routers/ticket.ts
@@ -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',
+ });
+ }),
+});
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ea1ba514..1e608286 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1243,6 +1243,9 @@ importers:
'@openpanel/validation':
specifier: workspace:*
version: link:../validation
+ '@seventy-seven/sdk':
+ specifier: 0.0.0-beta.2
+ version: 0.0.0-beta.2
'@trpc/server':
specifier: ^10.45.1
version: 10.45.1
@@ -6979,6 +6982,10 @@ packages:
join-component: 1.1.0
dev: false
+ /@seventy-seven/sdk@0.0.0-beta.2:
+ resolution: {integrity: sha512-aqRk1zvfEiq0yn20UwGvcQ1PGOLrGo3fSKZmhgKOesh8HFnimrduJ4uGJjpJUSmvvKafL/rC7wXCrMuwtSiYQg==}
+ dev: false
+
/@sideway/address@4.1.5:
resolution: {integrity: sha512-IqO/DUQHUkPeixNQ8n0JA6102hT9CmaljNTPmQ1u8MEhBo/R4Q8eKLN/vGZxuebwOroDB4cbpjheD4+/sKFK4Q==}
dependencies: