Merge branch 'main' of github.com:formbricks/formbricks into Validation-for-Reset-Password

This commit is contained in:
Johannes
2023-07-10 09:45:29 +02:00
69 changed files with 1418 additions and 435 deletions

View File

@@ -46,6 +46,7 @@ NEXTAUTH_URL=http://localhost:3000
# MAIL_FROM=noreply@example.com
# SMTP_HOST=localhost
# SMTP_PORT=1025
# Enable SMTP_SECURE_ENABLED for TLS (port 465)
# SMTP_SECURE_ENABLED=0 # Enable for TLS (port 465)
# SMTP_USER=smtpUser
# SMTP_PASSWORD=smtpPassword

View File

@@ -82,12 +82,6 @@ NEXT_PUBLIC_PRIVACY_URL=
NEXT_PUBLIC_TERMS_URL=
NEXT_PUBLIC_IMPRINT_URL=
# Disable Sentry warning
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
# Enable Sentry Error Tracking
NEXT_PUBLIC_SENTRY_DSN=
# Configure Github Login
NEXT_PUBLIC_GITHUB_AUTH_ENABLED=0
GITHUB_ID=
@@ -100,7 +94,6 @@ GOOGLE_CLIENT_SECRET=
# Stripe Billing Variables
NEXT_PUBLIC_STRIPE_PRICING_TABLE_ID=
NEXT_PUBLIC_STRIPE_PUBLIC_KEY=
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
@@ -108,4 +101,7 @@ STRIPE_WEBHOOK_SECRET=
# Configure Formbricks usage within Formbricks
NEXT_PUBLIC_FORMBRICKS_API_HOST=
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID=
# Cron Secret
CRON_SECRET=

View File

@@ -0,0 +1,23 @@
name: Cron - weeklySummary
on:
# "Scheduled workflows run on the latest commit on the default or base branch."
# — https://docs.github.com/en/actions/learn-github-actions/events-that-trigger-workflows#schedule
schedule:
# Runs “At 08:00 on Monday.” (see https://crontab.guru)
- cron: "0 8 * * 1"
jobs:
cron-weeklySummary:
env:
APP_URL: ${{ secrets.APP_URL }}
CRON_API_KEY: ${{ secrets.CRON_SECRET }}
runs-on: ubuntu-latest
steps:
- name: cURL request
if: ${{ env.APP_URL && env.CRON_SECRET }}
run: |
curl ${{ secrets.APP_URL }}/api/cron/weekly_summary \
-X POST \
-H 'content-type: application/json' \
-H 'authorization: ${{ secrets.CRON_SECRET }}' \
--fail

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ yarn-error.log*
.env.test.local
.env.production.local
!packages/database/.env
!apps/web/.env
# Prisma generated files
packages/database/zod

1
.vercelignore Normal file
View File

@@ -0,0 +1 @@
apps/web/.env

View File

@@ -5,10 +5,9 @@ import CrowdLogoDark from "@/images/clients/crowd-logo-dark.svg";
import CrowdLogoLight from "@/images/clients/crowd-logo-light.svg";
import NILogoDark from "@/images/clients/niLogoDark.svg";
import NILogoLight from "@/images/clients/niLogoWhite.svg";
import StackOceanLogoDark from "@/images/clients/stack-ocean-dark.png";
import StackOceanLogoLight from "@/images/clients/stack-ocean-light.png";
import AnimationFallback from "@/public/animations/fallback-image-open-source-feedback-software.jpg";
import { Button } from "@formbricks/ui";
import { ChevronRightIcon } from "@heroicons/react/24/outline";
import { usePlausible } from "next-plausible";
import Image from "next/image";
import { useRouter } from "next/router";
@@ -19,8 +18,15 @@ export const Hero: React.FC = ({}) => {
const router = useRouter();
return (
<div className="relative">
<div className="px-4 py-20 text-center sm:px-6 lg:px-8 lg:py-28">
<h1 className="text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<div className="px-4 pb-20 pt-16 text-center sm:px-6 lg:px-8 lg:pb-32 lg:pt-20">
<a
href="https://github.com/formbricks/formbricks"
target="_blank"
className="border-brand-dark rounded-full border px-6 py-1.5 text-sm text-slate-500 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800">
We&apos;re Open-Source | Star us on GitHub{" "}
<ChevronRightIcon className="inline h-5 w-5 text-slate-300" />
</a>
<h1 className="mt-10 text-3xl font-bold tracking-tight text-slate-800 dark:text-slate-200 sm:text-4xl md:text-5xl">
<span className="xl:inline">Create Products People Remember</span>
</h1>
@@ -29,7 +35,7 @@ export const Hero: React.FC = ({}) => {
<br />
<span className="hidden md:block">
Continuously gather deep user insights,{" "}
<span className="decoration-brand-dark underline underline-offset-4">all privacy-first.</span>
<span className="decoration-brand-dark underline underline-offset-4">privacy-first.</span>
</span>
</p>
@@ -37,7 +43,7 @@ export const Hero: React.FC = ({}) => {
<p className="hidden whitespace-nowrap pt-3 text-xs text-slate-400 dark:text-slate-500 md:block">
Trusted by
</p>
<div className="grid grid-cols-3 items-center gap-8 pt-2 md:grid-cols-5">
<div className="grid grid-cols-3 items-center gap-8 pt-2 md:grid-cols-4">
<Image
src={CalLogoLight}
alt="Cal Logo"
@@ -80,18 +86,6 @@ export const Hero: React.FC = ({}) => {
className="hidden pb-1 hover:opacity-100 dark:block md:opacity-50"
width={200}
/>
<Image
src={StackOceanLogoLight}
alt="StackOcean Logo"
className="block pb-1 hover:opacity-100 dark:hidden md:opacity-50"
width={200}
/>
<Image
src={StackOceanLogoDark}
alt="StakcOcean Logo"
className="hidden pb-1 hover:opacity-100 dark:block md:opacity-50"
width={200}
/>
</div>
</div>
<div className="hidden pt-10 md:block">

View File

@@ -84,7 +84,7 @@ export default function BestPracticeNavigation() {
<div className=" mx-auto grid grid-cols-1 gap-6 px-2 sm:grid-cols-3">
{BestPractices.map((bestPractice) => (
<Link href={bestPractice.href} key={bestPractice.name}>
<div className="drop-shadow-card duration-120 relative rounded-lg bg-slate-100 p-8 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:bg-slate-800">
<div className="drop-shadow-card duration-120 hover:border-brand-dark relative rounded-lg border border-slate-100 bg-slate-100 p-8 transition-all ease-in-out hover:scale-105 hover:cursor-pointer dark:bg-slate-800">
<div
className={clsx(
// base styles independent what type of button it is

View File

@@ -37,6 +37,7 @@ const tiers = [
features: [
"Unlimited surveys",
"Unlimited team members",
"Remove branding",
"Granular targeting",
"In-product surveys",
"Link surveys",
@@ -57,8 +58,8 @@ const tiers = [
discounted: false,
highlight: false,
description: "All features included. Unlimited usage.",
features: ["All features of Free plan", "Unlimited responses", "Remove branding"],
ctaName: "Sign up now",
features: ["Unlimited responses per survey"],
ctaName: "Start for free",
plausibleGoal: "Pricing_CTA_ProPlan",
},
];
@@ -145,7 +146,7 @@ export default function Pricing() {
{tier.ctaName}
</Button>
{tier.name === "Free" && (
{tier.name !== "Self-hosting" && (
<p className="mt-1.5 text-center text-xs text-slate-500">No Creditcard required.</p>
)}
</div>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -0,0 +1,122 @@
import Image from "next/image";
import LayoutMdx from "@/components/shared/LayoutMdx";
import TitleImage from "./formbricks-sponsored-by-github-accelerator-2023.webp";
import Demo from "./our-experience-github-acc-demo-screenshot.png";
import Mail from "./github-accelerator-selection-mail.png";
import Teams from "./github-accelerator-2022-teams.png";
import NewsletterSignup from "@/components/shared/NewsletterSignup";
export const meta = {
title: "Our GitHub Accelerator Experience 👀",
description:
"What we learned during the first GitHub Open-Source Accelerator Programm - our experience and if we would do it again.",
date: "2023-04-13",
};
_We were among the first 20 teams ever to run through the Open-Source Accelerator by Github. Read about our experience and if we would do it again:_
<Image
src={Demo}
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
className="rounded-lg"
/>
## Hey there,
In December of last year, we completed a rather brief questionnaire to apply for the inaugural batch of the GitHub Open-Source Accelerator. With not much information available, we went ahead and applied, hoping for the best. The timing couldn't have been more perfect, as both Matti and I had just wrapped up our freelance gigs to start working full-time on Formbricks.
As Christmas, New Year's Eve, and my birthday passed, we continued working diligently on Formbricks, iterating to pinpoint the right niche offering. Over the preceding months, we had learned what wouldn't constitute a good venture case ([Typeform open-source](https://formbricks.com/blog/open-source-qualtrics-beats-typeform)), what wasn't technically feasible (building blocks for all form and survey solutions), and what was too narrow to start with ([PMF survey only](https://www.producthunt.com/products/product-market-fit-survey-by-formbricks)).
January and February came and went. On the 22nd of March, we received an email from the GitHub team:
<Image
src={Mail}
alt="GitHub invited us to join the GitHub Accelerator and share our experience"
className="rounded-lg"
/>
Needless to say, we were thrilled! We were selected from over 1000 open-source projects, alongside renowned and popular projects like [Nuxt](https://github.com/nuxt/nuxt), [TRPC](https://github.com/trpc/trpc), and [Responsively App](https://github.com/responsively-org/responsively-app). Here is a summary of what we got:
### What we got on paper
✅ Ten sessions with **well-known** figures from the open-source community (Wednesdays)
✅ Ten optional co-working sessions (Fridays)
✅ 20.000 USD equally divided among core maintainers
✅ One-on-one session with the GitHub team to align on goals and objectives
### What we also gained
👌 Network of builders, maintainers, and founders in the open-source space
👌 Solid connection with GitHub (including a warm introduction to GitHub's venture arm 😏)
👌 Enhanced credibility in the open-source community, thanks to association with such a significant supporter of open source
I mean look at all these happy people:
<Image
src={Teams}
alt="GitHub invited us to join the GitHub Accelerator and share our experience"
className="rounded-lg"
/>
Here's an overview of the ten sessions and their relevance to us as a venture-focused startup:
**Week 1: Kick-Off, Licensing 101 and setting up with [Abby](https://twitter.com/abbycabs)**
Great to meet everyone, Abby is a great host and the licensing session was very useful. We had already decided on our license but it was useful nontheless.
**Week 2: Finding Sponsors with [Caleb Porzio](https://twitter.com/calebporzio)**
This was a really fun one! Caleb is a driven entrepreneur with many ideas and loooots of experience monetizing his two main projects [Livewire](https://laravel-livewire.com/) and [Alpine.js](https://alpinejs.dev/). Come up with a way to monetize a popular OS project, Caleb scaled it. Not suuuper relevant for us though.
**Week 3: Taking Funding: [Brian Douglas](https://twitter.com/bdougieYO)**
Brian is building [OpenSauced](https://opensauced.pizza/) and shared his journey of raising VC as an OS startup. Lots of great insights, Brian is super approachable 😊
**Week 4: [Evan You](https://evanyou.me/): Sustainable Open Source**
Evan You famously created Vue.js (which is on track to pass React in GitHub ⭐) and Vite. Evan had a lot of useful Dos and Donts for us, great session!
**Week 5:** Didn't happen due to Maintainer Summit.
**Week 6: [Mike Perham](https://github.com/mperham) - Starting a Software Business**
Mike is an absolute legend! With [SideKiq](https://sidekiq.org/) he makes over 300k USD per month 🤯 He was very open and down to earth. One of his best advice: If a customer annoys you, stop serving them. He was able to pull this off because he has been blogging about Ruby for years and is well-known in the community. And, obviously, his solution kicks ass!
**Week 7: Duane OBrien and Dawn Foster: Working with Enterprises**
Lots of useful insights around how enterprises handle open-source, barriers for corporate use and how to handle corporate sponsorships and donations. The notes will come in really handy down the line!
**Week 8: [Marko Saric](https://twitter.com/markosaric): SaaS-side of Open Source**
Marko is the marketing co-founder of [Plausible](https://plausible.io/). I think everyone in the SaaS space knows Plausible since they hit 1M ARR bootstrapped. Marko also blogged a lot about Plausible which really helped it grow in the first years. Gifted marketeer, great session!
**Week 9: Governance with [Shauna Gordon-McKeon](https://github.com/shaunagm/)**
For us this wasnt super relevant as Formbricks is ruled by a BDFL (Benevolent Dictator For Life) i.e. us but the discussion among the teams was really insightful. Helped us a great deal to understand the challenges of purely community-driven projects.
**Week 10: VC Funding and the legal Side of OSS with [Erica Brescia](https://twitter.com/ericabrescia) from Redpoint**
Ericas talk was really impressive and so is she: Founder of Bitnami, COO of GitHub and Board Member of the Linux Foundation all happened before she started as an investor at Redpoint Ventures. Her deep insights from both the founder and the VC perspective are invaluable!
**GitHub Demo Day:** All teams presented what they achieved during the 10 week programm. It was great fun to present Formbricks, [you can watch it on Youtube.](https://www.youtube.com/live/Gj6Bez2182k?feature=share&t=1448)
## Would we do it again? And should you?
Yes, absolutely. The sessions were excellent, we met a handful of inspiring builders, and the 20k USD was a helpful financial boost. The application process might evolve, but since all of your code is open-source anyway, you might as well throw your hat in the ring.
**Gratitude to Kara, Abby, and the entire GitHub team - we learned a lot! 😊**
<Image
src={TitleImage}
alt="GitHub sponsors Formbricks to join their open-source accelerator program"
className="rounded-lg"
/>
<NewsletterSignup />
export default ({ children }) => <LayoutMdx meta={meta}>{children}</LayoutMdx>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

1
apps/web/.env Symbolic link
View File

@@ -0,0 +1 @@
../../.env

View File

@@ -1,13 +1,14 @@
"use client";
import { env } from "@/env.mjs";
import { formbricksEnabled } from "@/lib/formbricks";
import formbricks from "@formbricks/js";
import { useEffect } from "react";
/* if (typeof window !== "undefined" && formbricksEnabled) {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
logLevel: "debug",
});
} */
@@ -16,8 +17,8 @@ export default function FormbricksClient({ session }) {
useEffect(() => {
if (formbricksEnabled && session.user && formbricks) {
formbricks.init({
environmentId: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
environmentId: env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID || "",
apiHost: env.NEXT_PUBLIC_FORMBRICKS_API_HOST || "",
});
formbricks.setUserId(session.user.id);
formbricks.setEmail(session.user.email);

View File

@@ -1,21 +1,22 @@
"use client";
import { env } from "@/env.mjs";
import { usePathname, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
const posthogEnabled = process.env.NEXT_PUBLIC_POSTHOG_API_KEY && process.env.NEXT_PUBLIC_POSTHOG_API_HOST;
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
// Check that PostHog is client-side (used to handle Next.js SSR)
if (
typeof window !== "undefined" &&
posthogEnabled &&
typeof process.env.NEXT_PUBLIC_POSTHOG_API_KEY === "string" &&
typeof process.env.NEXT_PUBLIC_POSTHOG_API_HOST === "string"
typeof env.NEXT_PUBLIC_POSTHOG_API_KEY === "string" &&
typeof env.NEXT_PUBLIC_POSTHOG_API_HOST === "string"
) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_API_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
posthog.init(env.NEXT_PUBLIC_POSTHOG_API_KEY, {
api_host: env.NEXT_PUBLIC_POSTHOG_API_HOST,
// Disable in development
loaded: (posthog) => {
if (process.env.NODE_ENV === "development") posthog.opt_out_capturing();

View File

@@ -1,3 +1,4 @@
import { env } from "@/env.mjs";
import { verifyPassword } from "@/lib/auth";
import { verifyToken } from "@/lib/jwt";
import { prisma } from "@formbricks/database";
@@ -120,12 +121,12 @@ export const authOptions: NextAuthOptions = {
},
}),
GitHubProvider({
clientId: process.env.GITHUB_ID || "",
clientSecret: process.env.GITHUB_SECRET || "",
clientId: env.GITHUB_ID || "",
clientSecret: env.GITHUB_SECRET || "",
}),
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID || "",
clientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
clientId: env.GOOGLE_CLIENT_ID || "",
clientSecret: env.GOOGLE_CLIENT_SECRET || "",
allowDangerousEmailAccountLinking: true,
}),
],
@@ -189,7 +190,7 @@ export const authOptions: NextAuthOptions = {
},
async signIn({ user, account }: any) {
if (account.provider === "credentials" || account.provider === "token") {
if (!user.emailVerified && process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
if (!user.emailVerified && env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
return `/auth/verification-requested?email=${encodeURIComponent(user.email)}`;
}
return true;

View File

@@ -0,0 +1,228 @@
import { sendEmail } from "@/lib/email";
import { withEmailTemplate } from "@/lib/email-template";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { Insights, NotificationResponse, Survey, SurveyResponse } from "./types";
const getEmailSubject = (productName: string) => {
return `${productName} User Insights - Last Week by Formbricks`;
};
const notificationHeader = (
productName: string,
startDate: string,
endDate: string,
startYear: number,
endYear: number
) =>
`
<div style="display: block; padding: 1rem;">
<div style="float: left;">
<h1>Hey 👋</h1>
</div>
<div style="float: right;">
<p style="text-align: right; margin: 0; font-weight: 600;">Weekly Report for ${productName}</p>
${getNotificationHeaderimePeriod(startDate, endDate, startYear, endYear)}
</div>
</div>
<br/>
<br/>
`;
const getNotificationHeaderimePeriod = (
startDate: string,
endDate: string,
startYear: number,
endYear: number
) => {
if (startYear == endYear) {
return `<p style="text-align: right; margin: 0;">${startDate} - ${endDate} ${endYear}</p>`;
} else {
return `<p style="text-align: right; margin: 0;">${startDate} ${startYear} - ${endDate} ${endYear}</p>`;
}
};
const notificationInsight = (insights: Insights) =>
`<div style="display: block;">
<table style="background-color: #f1f5f9; border-radius:1em; margin-top:1em; margin-bottom:1em;">
<tr>
<td style="text-align:center;">
<p style="font-size:0.9em">Surveys</p>
<h1>${insights.numLiveSurvey}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Displays</p>
<h1>${insights.totalDisplays}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Responses</p>
<h1>${insights.totalResponses}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Completed</p>
<h1>${insights.totalCompletedResponses}</h1>
</td>
<td style="text-align:center;">
<p style="font-size:0.9em">Completion %</p>
<h1>${insights.completionRate.toFixed(2)}%</h1>
</td>
</tr>
</table>
</div>
`;
function convertSurveyStatus(status) {
const statusMap = {
inProgress: "Live",
paused: "Paused",
completed: "Completed",
};
return statusMap[status] || status;
}
const getButtonLabel = (count) => {
if (count === 1) {
return "View Response";
}
return `View ${count > 2 ? count - 1 : "1"} more Response${count > 2 ? "s" : ""}`;
};
const notificationLiveSurveys = (surveys: Survey[], environmentId: string) => {
if (!surveys.length) return ` `;
return surveys
.filter((survey) => survey.responses.length > 0)
.map((survey) => {
const displayStatus = convertSurveyStatus(survey.status);
const isLive = displayStatus === "Live";
return `
<div style="display: block; margin-top:3em;">
<a href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses" style="color:#1e293b;">
<h2 style="text-decoration: underline; display:inline;">${survey.name}</h2>
</a>
<span style="display: inline; margin-left: 10px; background-color: ${
isLive ? "#34D399" : "#a7f3d0"
}; color: ${isLive ? "#F3F4F6" : "#15803d"}; border-radius:99px; padding: 2px 8px; font-size:0.9em">
${displayStatus}
</span>
${createSurveyFields(survey.responses)}
${
survey.responsesCount >= 1
? `<a class="button" href="${WEBAPP_URL}/environments/${environmentId}/surveys/${
survey.id
}/responses" style="background: #1e293b; margin-top:1em; font-size:0.9em; font-weight:500">
${getButtonLabel(survey.responsesCount)}
</a>`
: ""
}
<br/></div><br/>`;
})
.join("");
};
const createSurveyFields = (surveryResponses: SurveyResponse[]) => {
let surveyFields = "";
const responseCount = surveryResponses.length;
surveryResponses.forEach((response, index) => {
if (!response) {
return;
}
for (const [headline, answer] of Object.entries(response)) {
surveyFields += `
<div style="margin-top:1em;">
<p style="margin:0px;">${headline}</p>
<p style="font-weight: 500; margin:0px;">${answer}</p>
</div>
`;
}
// Add <hr/> only when there are 2 or more responses to display, and it's not the last response
if (responseCount >= 2 && index < responseCount - 1) {
surveyFields += "<hr/>";
}
});
return surveyFields;
};
const notificationFooter = () => {
return `
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
};
const createReminderNotificationBody = (notificationData: NotificationResponse, webUrl) => {
return `
<p>Wed love to send you a Weekly Summary, but currently there are no surveys running for ${notificationData.productName}.</p>
<p style="font-weight: bold; padding-top:1em;">Dont let a week pass without learning about your users:</p>
<a class="button" href="${webUrl}/environments/${notificationData.environmentId}/surveys" style="background: #1e293b; font-size:0.9em; font-weight:500">Setup a new survey</a>
<br/>
<p style="padding-top:1em;">Need help finding the right survey for your product? Pick a 15-minute slot <a href="https://cal.com/johannes/15">in our CEOs calendar</a> or reply to this email :)</p>
<p style="margin-bottom:0px; padding-top:1em; font-weight:500">All the best,</p>
<p style="margin-top:0px;">The Formbricks Team</p>
<div style="margin-top:0.8em; background-color:#f1f5f9; border-radius:99px; margin:1em; padding:0.01em 1.6em; text-align:center;"><p><i>This is a Beta feature. If you experience any issues, please let us know by replying to this email 🙏</i></p></div>
`;
};
export const sendWeeklySummaryNotificationEmail = async (
email: string,
notificationData: NotificationResponse
) => {
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const startDate = `${notificationData.lastWeekDate.getDate()} ${
monthNames[notificationData.lastWeekDate.getMonth()]
}`;
const endDate = `${notificationData.currentDate.getDate()} ${
monthNames[notificationData.currentDate.getMonth()]
}`;
const startYear = notificationData.lastWeekDate.getFullYear();
const endYear = notificationData.currentDate.getFullYear();
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.productName),
html: withEmailTemplate(`
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${notificationInsight(notificationData.insights)}
${notificationLiveSurveys(notificationData.surveys, notificationData.environmentId)}
${notificationFooter()}
`),
});
};
export const sendNoLiveSurveyNotificationEmail = async (
email: string,
notificationData: NotificationResponse
) => {
const monthNames = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
const startDate = `${notificationData.lastWeekDate.getDate()} ${
monthNames[notificationData.lastWeekDate.getMonth()]
}`;
const endDate = `${notificationData.currentDate.getDate()} ${
monthNames[notificationData.currentDate.getMonth()]
}`;
const startYear = notificationData.lastWeekDate.getFullYear();
const endYear = notificationData.currentDate.getFullYear();
await sendEmail({
to: email,
subject: getEmailSubject(notificationData.productName),
html: withEmailTemplate(`
${notificationHeader(notificationData.productName, startDate, endDate, startYear, endYear)}
${createReminderNotificationBody(notificationData, WEBAPP_URL)}
`),
});
};

View File

@@ -0,0 +1,187 @@
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { CRON_SECRET } from "@formbricks/lib/constants";
import { headers } from "next/headers";
import { NextResponse } from "next/server";
import { sendNoLiveSurveyNotificationEmail, sendWeeklySummaryNotificationEmail } from "./email";
import { EnvironmentData, NotificationResponse, ProductData, Survey, SurveyResponse } from "./types";
export async function POST(): Promise<NextResponse> {
// check authentication with x-api-key header and CRON_SECRET env variable
if (headers().get("x-api-key") !== CRON_SECRET) {
return responses.notAuthenticatedResponse();
}
// list of email sending promises to wait for
const emailSendingPromises: Promise<void>[] = [];
const products = await getProducts();
// iterate through the products and send weekly summary email to each team member
for await (const product of products) {
// check if there are team members that have weekly summary notification enabled
const teamMembers = product.team.memberships;
const teamMembersWithNotificationEnabled = teamMembers.filter((member) => {
return (
member.user.notificationSettings?.weeklySummary &&
member.user.notificationSettings.weeklySummary[product.id]
);
});
// if there are no team members with weekly summary notification enabled, skip to the next product (do not send email)
if (teamMembersWithNotificationEnabled.length == 0) {
continue;
}
// calculate insights for the product
const notificationResponse = getNotificationResponse(product.environments[0], product.name);
// if there were no responses in the last 7 days, send a different email
if (notificationResponse.insights.totalCompletedResponses == 0) {
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendNoLiveSurveyNotificationEmail(teamMember.user.email, notificationResponse)
);
}
continue;
}
// send weekly summary email
for (const teamMember of teamMembersWithNotificationEnabled) {
emailSendingPromises.push(
sendWeeklySummaryNotificationEmail(teamMember.user.email, notificationResponse)
);
}
}
// wait for all emails to be sent
await Promise.all(emailSendingPromises);
return responses.successResponse({}, true);
}
const getNotificationResponse = (environment: EnvironmentData, productName: string): NotificationResponse => {
const insights = {
totalCompletedResponses: 0,
totalDisplays: 0,
totalResponses: 0,
completionRate: 0,
numLiveSurvey: 0,
};
const surveys: Survey[] = [];
// iterate through the surveys and calculate the overall insights
for (const survey of environment.surveys) {
const surveyData: Survey = {
id: survey.id,
name: survey.name,
status: survey.status,
responsesCount: survey.responses.length,
responses: [],
};
// iterate through the responses and calculate the survey insights
for (const response of survey.responses) {
// only take the first 3 responses
if (surveyData.responses.length >= 1) {
break;
}
const surveyResponse: SurveyResponse = {};
for (const question of survey.questions) {
const headline = question.headline;
const answer = response.data[question.id]?.toString() || null;
if (answer === null || answer === "" || answer?.length === 0) {
continue;
}
surveyResponse[headline] = answer;
}
surveyData.responses.push(surveyResponse);
}
surveys.push(surveyData);
// calculate the overall insights
if (survey.status == "inProgress") {
insights.numLiveSurvey += 1;
}
insights.totalCompletedResponses += survey.responses.filter((r) => r.finished).length;
insights.totalDisplays += survey.displays.length;
insights.totalResponses += survey.responses.length;
insights.completionRate = Math.round((insights.totalCompletedResponses / insights.totalDisplays) * 100);
}
// build the notification response needed for the emails
const lastWeekDate = new Date();
lastWeekDate.setDate(lastWeekDate.getDate() - 7);
return {
environmentId: environment.id,
currentDate: new Date(),
lastWeekDate,
productName: productName,
surveys,
insights,
};
};
const getProducts = async (): Promise<ProductData[]> => {
// gets all products together with team members, surveys, responses, and displays for the last 7 days
const sevenDaysAgo = new Date();
sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
return await prisma.product.findMany({
select: {
id: true,
name: true,
environments: {
where: {
type: "production",
},
select: {
id: true,
surveys: {
where: {
status: {
not: "draft",
},
},
select: {
id: true,
name: true,
questions: true,
status: true,
responses: {
where: {
createdAt: {
gte: sevenDaysAgo,
},
},
select: {
id: true,
createdAt: true,
updatedAt: true,
finished: true,
data: true,
},
orderBy: {
createdAt: "desc",
},
},
displays: {
select: {
status: true,
},
},
},
},
},
},
team: {
select: {
memberships: {
select: {
user: {
select: {
email: true,
notificationSettings: true,
},
},
},
},
},
},
},
});
};

View File

@@ -0,0 +1,81 @@
import { TResponseData } from "@formbricks/types/v1/responses";
import { TSurveyQuestion } from "@formbricks/types/v1/surveys";
import { TUserNotificationSettings } from "@formbricks/types/v1/users";
import { DisplayStatus, SurveyStatus } from "@prisma/client";
export interface Insights {
totalCompletedResponses: number;
totalDisplays: number;
totalResponses: number;
completionRate: number;
numLiveSurvey: number;
}
export interface SurveyResponse {
[headline: string]: string | number | boolean | Date | string[];
}
export interface Survey {
id: string;
name: string;
responses: SurveyResponse[];
responsesCount: number;
status: string;
}
export interface NotificationResponse {
environmentId: string;
currentDate: Date;
lastWeekDate: Date;
productName: string;
surveys: Survey[];
insights: Insights;
}
// Prisma Types
type ResponseData = {
id: string;
createdAt: Date;
updatedAt: Date;
finished: boolean;
data: TResponseData;
};
type DisplayData = {
status: DisplayStatus;
};
type SurveyData = {
id: string;
name: string;
questions: TSurveyQuestion[];
status: SurveyStatus;
responses: ResponseData[];
displays: DisplayData[];
};
export type EnvironmentData = {
id: string;
surveys: SurveyData[];
};
type UserData = {
email: string;
notificationSettings: TUserNotificationSettings;
};
type MembershipData = {
user: UserData;
};
type TeamData = {
memberships: MembershipData[];
};
export type ProductData = {
id: string;
name: string;
environments: EnvironmentData[];
team: TeamData;
};

View File

@@ -1,10 +1,5 @@
/*
THIS FILE IS WORK IN PROGRESS
PLEASE DO NOT USE IT YET
*/
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { markDisplayResponded } from "@formbricks/lib/services/displays";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -14,21 +9,21 @@ export async function OPTIONS(): Promise<NextResponse> {
export async function POST(_: Request, { params }: { params: { displayId: string } }): Promise<NextResponse> {
const { displayId } = params;
const display = await prisma.display.update({
where: {
id: displayId,
},
data: {
status: "responded",
},
});
if (!displayId) {
return responses.badRequestResponse("Missing displayId");
}
return responses.successResponse(
{
...display,
createdAt: display.createdAt.toISOString(),
updatedAt: display.updatedAt.toISOString(),
},
true
);
try {
const display = await markDisplayResponded(displayId);
return responses.successResponse(
{
...display,
createdAt: display.createdAt.toISOString(),
updatedAt: display.updatedAt.toISOString(),
},
true
);
} catch (error) {
return responses.internalServerErrorResponse(error.message);
}
}

View File

@@ -1,11 +1,11 @@
/*
THIS FILE IS WORK IN PROGRESS
PLEASE DO NOT USE IT YET
*/
import { responses } from "@/lib/api/response";
import { prisma } from "@formbricks/database";
import { transformErrorToDetails } from "@/lib/api/validator";
import { InvalidInputError } from "@formbricks/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createDisplay } from "@formbricks/lib/services/displays";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { TDisplay, ZDisplayInput } from "@formbricks/types/v1/displays";
import { NextResponse } from "next/server";
export async function OPTIONS(): Promise<NextResponse> {
@@ -13,76 +13,50 @@ export async function OPTIONS(): Promise<NextResponse> {
}
export async function POST(request: Request): Promise<NextResponse> {
const { surveyId, personId, environmentId } = await request.json();
const jsonInput: unknown = await request.json();
const inputValidation = ZDisplayInput.safeParse(jsonInput);
if (!surveyId) {
return responses.missingFieldResponse("surveyId", true);
if (!inputValidation.success) {
return responses.badRequestResponse(
"Fields are missing or incorrectly formatted",
transformErrorToDetails(inputValidation.error),
true
);
}
if (!environmentId) {
return responses.missingFieldResponse("environmentId", true);
const displayInput = inputValidation.data;
// find environmentId from surveyId
let survey;
try {
survey = await getSurvey(displayInput.surveyId);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
return responses.internalServerErrorResponse(error.message);
}
}
// get teamId from environment
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
team: {
select: {
id: true,
memberships: {
select: {
userId: true,
role: true,
},
},
},
},
},
},
},
});
// find teamId & teamOwnerId from environmentId
const teamDetails = await getTeamDetails(survey.environmentId);
if (!environment) {
return responses.notFoundResponse("Environment", environmentId, true);
// create display
let display: TDisplay;
try {
display = await createDisplay(displayInput);
} catch (error) {
if (error instanceof InvalidInputError) {
return responses.badRequestResponse(error.message);
} else {
return responses.internalServerErrorResponse(error.message);
}
}
const teamId = environment.product.team.id;
// find team owner
const teamOwnerId = environment.product.team.memberships.find((m) => m.role === "owner")?.userId;
const createBody: any = {
select: {
id: true,
},
data: {
status: "seen",
survey: {
connect: {
id: surveyId,
},
},
},
};
if (personId) {
createBody.data.person = {
connect: {
id: personId,
},
};
}
// create new display
const display = await prisma.display.create(createBody);
if (teamOwnerId) {
await capturePosthogEvent(teamOwnerId, "display created", teamId, {
surveyId,
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "display created", teamDetails.teamId, {
surveyId: displayInput.surveyId,
});
} else {
console.warn("Posthog capture not possible. No team owner found");

View File

@@ -16,6 +16,11 @@ export async function PUT(
{ params }: { params: { responseId: string } }
): Promise<NextResponse> {
const { responseId } = params;
if (!responseId) {
return responses.badRequestResponse("Response ID is missing", undefined, true);
}
const responseUpdate = await request.json();
const inputValidation = ZResponseUpdateInput.safeParse(responseUpdate);

View File

@@ -1,13 +1,13 @@
import { responses } from "@/lib/api/response";
import { transformErrorToDetails } from "@/lib/api/validator";
import { sendToPipeline } from "@/lib/pipelines";
import { prisma } from "@formbricks/database";
import { InvalidInputError } from "@formbricks/errors";
import { capturePosthogEvent } from "@formbricks/lib/posthogServer";
import { createResponse } from "@formbricks/lib/services/response";
import { getSurvey } from "@formbricks/lib/services/survey";
import { getTeamDetails } from "@formbricks/lib/services/teamDetails";
import { captureTelemetry } from "@formbricks/lib/telemetry";
import { TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
import { TResponse, TResponseInput, ZResponseInput } from "@formbricks/types/v1/responses";
import { NextResponse } from "next/server";
import { UAParser } from "ua-parser-js";
@@ -40,41 +40,9 @@ export async function POST(request: Request): Promise<NextResponse> {
}
}
// prisma call to get the teamId
// TODO use services
const environment = await prisma.environment.findUnique({
where: { id: survey.environmentId },
include: {
product: {
select: {
team: {
select: {
id: true,
memberships: {
where: { role: "owner" },
select: { userId: true },
take: 1,
},
},
},
},
},
},
});
if (!environment) {
return responses.internalServerErrorResponse("Environment not found");
}
const {
product: {
team: { id: teamId, memberships },
},
} = environment;
const teamOwnerId = memberships[0]?.userId;
let response;
const teamDetails = await getTeamDetails(survey.environmentId);
let response: TResponse;
try {
const meta = {
userAgent: {
@@ -113,8 +81,9 @@ export async function POST(request: Request): Promise<NextResponse> {
}
captureTelemetry("response created");
if (teamOwnerId) {
await capturePosthogEvent(teamOwnerId, "response created", teamId, {
if (teamDetails?.teamOwnerId) {
await capturePosthogEvent(teamDetails.teamOwnerId, "response created", teamDetails.teamId, {
surveyId: response.surveyId,
surveyType: survey.type,
});

View File

@@ -12,7 +12,21 @@ export async function GET() {
hashedKey: hashApiKey(apiKey),
},
select: {
environment: true,
environment: {
select: {
id: true,
createdAt: true,
updatedAt: true,
type: true,
product: {
select: {
id: true,
name: true,
},
},
widgetSetupCompleted: true,
},
},
},
});
if (!apiKeyData) {

View File

@@ -3,14 +3,11 @@ import { verifyInviteToken } from "@/lib/jwt";
import { populateEnvironment } from "@/lib/populate";
import { prisma } from "@formbricks/database";
import { NextResponse } from "next/server";
import { env } from "@/env.mjs";
export async function POST(request: Request) {
let { inviteToken, ...user } = await request.json();
if (
inviteToken
? process.env.NEXT_PUBLIC_INVITE_DISABLED === "1"
: process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
) {
if (inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1") {
return NextResponse.json({ error: "Signup disabled" }, { status: 403 });
}
user = { ...user, ...{ email: user.email.toLowerCase() } };
@@ -99,7 +96,7 @@ export async function POST(request: Request) {
await prisma.invite.delete({ where: { id: inviteId } });
}
if (process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
if (env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED !== "1") {
await sendVerificationEmail(userData);
}
return NextResponse.json(userData);

View File

@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
import { SignupForm } from "@/components/auth/SignupForm";
import FormWrapper from "@/components/auth/FormWrapper";
import Testimonial from "@/components/auth/Testimonial";
import { env } from "@/env.mjs";
export default function SignUpPage() {
const searchParams = useSearchParams();
@@ -18,9 +19,7 @@ export default function SignUpPage() {
<div className="col-span-3 flex flex-col items-center justify-center">
<FormWrapper>
{(
inviteToken
? process.env.NEXT_PUBLIC_INVITE_DISABLED === "1"
: process.env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
inviteToken ? env.NEXT_PUBLIC_INVITE_DISABLED === "1" : env.NEXT_PUBLIC_SIGNUP_DISABLED === "1"
) ? (
<>
<h1 className="leading-2 mb-4 text-center font-bold">Sign up disabled</h1>

View File

@@ -1,9 +1,10 @@
"use client";
import { env } from "@/env.mjs";
import type { Session } from "next-auth";
import { usePostHog } from "posthog-js/react";
import { useEffect } from "react";
const posthogEnabled = process.env.NEXT_PUBLIC_POSTHOG_API_KEY && process.env.NEXT_PUBLIC_POSTHOG_API_HOST;
const posthogEnabled = env.NEXT_PUBLIC_POSTHOG_API_KEY && env.NEXT_PUBLIC_POSTHOG_API_HOST;
export default function PosthogIdentify({ session }: { session: Session }) {
const posthog = usePostHog();

View File

@@ -8,6 +8,7 @@ export default function SettingsCard({
soon = false,
noPadding = false,
dangerZone,
beta,
}: {
title: string;
description: string;
@@ -15,18 +16,19 @@ export default function SettingsCard({
soon?: boolean;
noPadding?: boolean;
dangerZone?: boolean;
beta?: boolean;
}) {
return (
<div className="my-4 w-full bg-white shadow sm:rounded-lg">
<div className="rounded-t-lg border-b border-slate-200 bg-slate-100 px-6 py-5">
<div className="flex">
<h3
className={`${
dangerZone ? "text-red-600" : "text-slate-900"
} "mr-2 text-lg font-medium leading-6 `}>
<h3 className={`${dangerZone ? "text-red-600" : "text-slate-900"} "text-lg font-medium leading-6 `}>
{title}
</h3>
{soon && <Badge text="coming soon" size="normal" type="success" />}
<div className="ml-2">
{beta && <Badge text="Beta" size="normal" type="warning" />}
{soon && <Badge text="coming soon" size="normal" type="success" />}
</div>
</div>
<p className="mt-1 text-sm text-slate-500">{description}</p>
</div>

View File

@@ -1,7 +1,10 @@
"use client";
import ShareInviteModal from "@/app/environments/[environmentId]/settings/members/ShareInviteModal";
import DeleteDialog from "@/components/shared/DeleteDialog";
import LoadingSpinner from "@/components/shared/LoadingSpinner";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import { env } from "@/env.mjs";
import {
addMember,
deleteInvite,
@@ -12,6 +15,8 @@ import {
updateMemberRole,
useMembers,
} from "@/lib/members";
import { useProfile } from "@/lib/profile";
import { capitalizeFirstLetter } from "@/lib/utils";
import {
Badge,
Button,
@@ -28,15 +33,11 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@formbricks/ui";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import { PaperAirplaneIcon, ShareIcon, TrashIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import toast from "react-hot-toast";
import AddMemberModal from "./AddMemberModal";
import CreateTeamModal from "@/components/team/CreateTeamModal";
import { capitalizeFirstLetter } from "@/lib/utils";
import { useProfile } from "@/lib/profile";
import { ChevronDownIcon } from "@heroicons/react/20/solid";
import ShareInviteModal from "@/app/environments/[environmentId]/settings/members/ShareInviteModal";
type EditMembershipsProps = {
environmentId: string;
@@ -191,7 +192,7 @@ export function EditMemberships({ environmentId }: EditMembershipsProps) {
}}>
Create New Team
</Button>
{process.env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
{env.NEXT_PUBLIC_INVITE_DISABLED !== "1" && isAdminOrOwner && (
<Button
variant="darkCTA"
onClick={() => {

View File

@@ -1,39 +0,0 @@
"use client";
import { Switch } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { updateNotificationSettings } from "./actions";
interface AlertSwitchProps {
surveyId: string;
userId: string;
notificationSettings: any;
}
export function AlertSwitch({ surveyId, userId, notificationSettings }: AlertSwitchProps) {
const router = useRouter();
return (
<Switch
id="every-submission"
aria-label="toggle every submission"
checked={notificationSettings[surveyId]["responseFinished"]}
onCheckedChange={async () => {
// update notificiation settings
const updatedNotificationSettings = { ...notificationSettings };
updatedNotificationSettings[surveyId]["responseFinished"] =
!updatedNotificationSettings[surveyId]["responseFinished"];
// update db
await updateNotificationSettings(userId, notificationSettings);
// show success message if toggled on, different message if toggled off
if (updatedNotificationSettings[surveyId]["responseFinished"]) {
toast.success(`Every new response is coming your way.`);
} else {
toast.success(`You won't receive notifications anymore.`);
}
router.refresh();
}}
/>
);
}

View File

@@ -1,32 +1,9 @@
import { AlertSwitch } from "./AlertSwitch";
import { Switch, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@formbricks/ui";
import { QuestionMarkCircleIcon, UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import type { NotificationSettings } from "@formbricks/types/users";
import { NotificationSwitch } from "./NotificationSwitch";
import { Membership, User } from "./types";
const cleanNotificationSettings = (notificationSettings: NotificationSettings, memberships: Membership[]) => {
const newNotificationSettings = {};
for (const membership of memberships) {
for (const product of membership.team.products) {
for (const environment of product.environments) {
for (const survey of environment.surveys) {
// check if the user has notification settings for this survey
if (notificationSettings[survey.id]) {
newNotificationSettings[survey.id] = notificationSettings[survey.id];
} else {
newNotificationSettings[survey.id] = {
responseFinished: false,
weeklySummary: false,
};
}
}
}
}
}
return newNotificationSettings;
};
interface EditAlertsProps {
memberships: Membership[];
user: User;
@@ -34,8 +11,6 @@ interface EditAlertsProps {
}
export default function EditAlerts({ memberships, user, environmentId }: EditAlertsProps) {
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
return (
<>
{memberships.map((membership) => (
@@ -47,9 +22,9 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
<p className="text-slate-800">{membership.team.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-5 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-1">Product</div>
<div className="grid h-12 grid-cols-4 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div className="col-span-2">Survey</div>
<div className="col-span-1">Product</div>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
@@ -60,48 +35,45 @@ export default function EditAlerts({ memberships, user, environmentId }: EditAle
<TooltipContent>Sends complete responses, no partials.</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider delayDuration={50}>
<Tooltip>
<TooltipTrigger>
<div className="col-span-1 cursor-default text-center">Weekly Summary</div>
</TooltipTrigger>
<TooltipContent>Coming soon 🚀</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="grid-cols-8 space-y-1 p-2">
{membership.team.products.map((product) => (
<div key={product.id}>
{product.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-5 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={survey.name}>
<div className="col-span-1 flex flex-col justify-center break-all">
{product?.name}
{membership.team.products.some((product) =>
product.environments.some((environment) => environment.surveys.length > 0)
) ? (
<div className="grid-cols-8 space-y-1 p-2">
{membership.team.products.map((product) => (
<div key={product.id}>
{product.environments.map((environment) => (
<div key={environment.id}>
{environment.surveys.map((survey) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-4 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={survey.name}>
<div className=" col-span-2 flex items-center ">
<p className="text-slate-800">{survey.name}</p>
</div>
<div className="col-span-1 flex flex-col justify-center break-all">
{product?.name}
</div>
<div className="col-span-1 text-center">
<NotificationSwitch
surveyOrProductId={survey.id}
userId={user.id}
notificationSettings={user.notificationSettings}
notificationType={"alert"}
/>
</div>
</div>
<div className=" col-span-2 flex items-center ">
<p className="text-slate-800">{survey.name}</p>
</div>
<div className="col-span-1 text-center">
<AlertSwitch
surveyId={survey.id}
userId={user.id}
notificationSettings={user.notificationSettings}
/>
</div>
<div className="col-span-1 text-center">
<Switch disabled id="weekly-summary" aria-label="toggle weekly summary" />
</div>
</div>
))}
</div>
))}
</div>
))}
</div>
))}
</div>
))}
</div>
))}
</div>
) : (
<div className="m-2 flex h-16 items-center justify-center rounded bg-slate-50 text-sm text-slate-500">
<p>No surveys found.</p>
</div>
)}
<p className="pb-3 pl-4 text-xs text-slate-400">
Want to loop in team mates?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>

View File

@@ -0,0 +1,56 @@
import { UsersIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { NotificationSwitch } from "./NotificationSwitch";
import { Membership, User } from "./types";
interface EditAlertsProps {
memberships: Membership[];
user: User;
environmentId: string;
}
export default function EditWeeklySummary({ memberships, user, environmentId }: EditAlertsProps) {
return (
<>
{memberships.map((membership) => (
<>
<div className="mb-5 flex items-center space-x-3 font-semibold">
<div className="rounded-full bg-slate-100 p-1">
<UsersIcon className="h-6 w-7 text-slate-600" />
</div>
<p className="text-slate-800">{membership.team.name}</p>
</div>
<div className="mb-6 rounded-lg border border-slate-200">
<div className="grid h-12 grid-cols-2 content-center rounded-t-lg bg-slate-100 px-4 text-left text-sm font-semibold text-slate-900">
<div>Product</div>
<div className="cursor-default pr-12 text-right">Weekly Summary</div>
</div>
<div className="grid-cols-8 space-y-1 p-2">
{membership.team.products.map((product) => (
<div
className="grid h-auto w-full cursor-pointer grid-cols-2 place-content-center rounded-lg px-2 py-2 text-left text-sm text-slate-900 hover:bg-slate-50"
key={product.id}>
<div>{product?.name}</div>
<div className="mr-20 flex justify-end">
<NotificationSwitch
surveyOrProductId={product.id}
userId={user.id}
notificationSettings={user.notificationSettings}
notificationType={"weeklySummary"}
/>
</div>
</div>
))}
</div>
<p className="pb-3 pl-4 text-xs text-slate-400">
Want to loop in team mates?{" "}
<Link className="font-semibold" href={`/environments/${environmentId}/settings/members`}>
Invite them.
</Link>
</p>
</div>
</>
))}
</>
);
}

View File

@@ -0,0 +1,45 @@
"use client";
import { Switch } from "@formbricks/ui";
import { useRouter } from "next/navigation";
import toast from "react-hot-toast";
import { updateNotificationSettings } from "./actions";
import { NotificationSettings } from "@formbricks/types/users";
import { useState } from "react";
interface NotificationSwitchProps {
surveyOrProductId: string;
userId: string;
notificationSettings: NotificationSettings;
notificationType: "alert" | "weeklySummary";
}
export function NotificationSwitch({
surveyOrProductId,
userId,
notificationSettings,
notificationType,
}: NotificationSwitchProps) {
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
return (
<Switch
id="notification-switch"
aria-label="toggle notification settings"
checked={notificationSettings[notificationType][surveyOrProductId]}
disabled={isLoading}
onCheckedChange={async () => {
setIsLoading(true);
// update notificiation settings
const updatedNotificationSettings = { ...notificationSettings };
updatedNotificationSettings[notificationType][surveyOrProductId] =
!updatedNotificationSettings[notificationType][surveyOrProductId];
await updateNotificationSettings(userId, notificationSettings);
setIsLoading(false);
toast.success(`Notification settings updated`, { id: "notification-switch" });
router.refresh();
}}
/>
);
}

View File

@@ -1,9 +1,11 @@
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import SettingsCard from "@/app/environments/[environmentId]/settings/SettingsCard";
import { prisma } from "@formbricks/database";
import { NotificationSettings } from "@formbricks/types/users";
import { getServerSession } from "next-auth";
import SettingsTitle from "../SettingsTitle";
import EditAlerts from "./EditAlerts";
import { prisma } from "@formbricks/database";
import { getServerSession } from "next-auth";
import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import EditWeeklySummary from "./EditWeeklySummary";
import type { Membership, User } from "./types";
async function getUser(userId: string | undefined): Promise<User> {
@@ -29,6 +31,27 @@ async function getUser(userId: string | undefined): Promise<User> {
return user;
}
function cleanNotificationSettings(notificationSettings: NotificationSettings, memberships: Membership[]) {
const newNotificationSettings = { alert: {}, weeklySummary: {} };
for (const membership of memberships) {
for (const product of membership.team.products) {
// set default values for weekly summary
newNotificationSettings.weeklySummary[product.id] =
(notificationSettings.weeklySummary && notificationSettings.weeklySummary[product.id]) || false;
// set default values for alerts
for (const environment of product.environments) {
for (const survey of environment.surveys) {
newNotificationSettings.alert[survey.id] =
notificationSettings[survey.id]?.responseFinished ||
(notificationSettings.alert && notificationSettings.alert[survey.id]) ||
false; // check for legacy notification settings w/o "alerts" key
}
}
}
}
return newNotificationSettings;
}
async function getMemberships(userId: string): Promise<Membership[]> {
const memberships = await prisma.membership.findMany({
where: {
@@ -72,13 +95,22 @@ export default async function ProfileSettingsPage({ params }) {
throw new Error("Unauthorized");
}
const [user, memberships] = await Promise.all([getUser(session.user.id), getMemberships(session.user.id)]);
user.notificationSettings = cleanNotificationSettings(user.notificationSettings, memberships);
return (
<div>
<SettingsTitle title="Notifications" />
<SettingsCard title="Email alerts" description="Set up an alert to get an email on new responses.">
<SettingsCard
title="Email alerts (Surveys)"
description="Set up an alert to get an email on new responses.">
<EditAlerts memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
<SettingsCard
beta
title="Weekly summary (Products)"
description="Stay up-to-date with a Weekly every Monday.">
<EditWeeklySummary memberships={memberships} user={user} environmentId={params.environmentId} />
</SettingsCard>
</div>
);
}

View File

@@ -20,6 +20,8 @@ export default function UpdateQuestionId({ localSurvey, question, questionIdx, u
toast.success("Question ID updated.");
};
const isInputInvalid = currentValue.trim() === "" || currentValue.includes(" ");
return (
<div>
<Label htmlFor="questionId">Question ID</Label>
@@ -30,13 +32,14 @@ export default function UpdateQuestionId({ localSurvey, question, questionIdx, u
value={currentValue}
onChange={(e) => setCurrentValue(e.target.value)}
disabled={localSurvey.status !== "draft"}
className={isInputInvalid ? "focus:border-red-300 border-red-300" : ""}
/>
{localSurvey.status === "draft" && (
<Button
variant="darkCTA"
className="ml-2 bg-slate-600 text-white hover:bg-slate-700 disabled:bg-slate-400"
onClick={saveAction}
disabled={currentValue === question.id}>
disabled={isInputInvalid || currentValue === question.id}>
<CheckIcon className="h-4 w-4" />
</Button>
)}

View File

@@ -9,6 +9,9 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/authOptions";
import { getAnalysisData } from "@/app/environments/[environmentId]/surveys/[surveyId]/summary/data";
export default async function ResponsesPage({ params }) {
const environmentId = params.environmentId;
console.log(environmentId);
const session = await getServerSession(authOptions);
if (!session) {
throw new Error("Unauthorized");

View File

@@ -2,6 +2,7 @@
import Headline from "@/components/preview/Headline";
import Subheader from "@/components/preview/Subheader";
import { env } from "@/env.mjs";
import { formbricksEnabled, updateResponse } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
@@ -49,11 +50,7 @@ const Objective: React.FC<ObjectiveProps> = ({ next, skip, formbricksResponseId
console.error(e);
toast.error("An error occured saving your settings");
}
if (
formbricksEnabled &&
process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID &&
formbricksResponseId
) {
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID && formbricksResponseId) {
const res = await updateResponse(
formbricksResponseId,
{

View File

@@ -3,14 +3,14 @@
import { cn } from "@/../../packages/lib/cn";
import Headline from "@/components/preview/Headline";
import Subheader from "@/components/preview/Subheader";
import { env } from "@/env.mjs";
import { createResponse, formbricksEnabled } from "@/lib/formbricks";
import { useProfile } from "@/lib/profile";
import { useProfileMutation } from "@/lib/profile/mutateProfile";
import { SurveyId } from "@formbricks/js";
import { ResponseId, SurveyId } from "@formbricks/js";
import { Button } from "@formbricks/ui";
import { useState } from "react";
import { toast } from "react-hot-toast";
import { ResponseId } from "@formbricks/js";
type RoleProps = {
next: () => void;
@@ -48,13 +48,10 @@ const Role: React.FC<RoleProps> = ({ next, skip, setFormbricksResponseId }) => {
toast.error("An error occured saving your settings");
console.error(e);
}
if (formbricksEnabled && process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
const res = await createResponse(
process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID as SurveyId,
{
role: selectedRole.label,
}
);
if (formbricksEnabled && env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID) {
const res = await createResponse(env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID as SurveyId, {
role: selectedRole.label,
});
if (res.ok) {
const response = res.data;
setFormbricksResponseId(response.id);

View File

@@ -1,6 +1,7 @@
"use client";
import { GoogleButton } from "@/components/auth/GoogleButton";
import { env } from "@/env.mjs";
import { Button, PasswordInput } from "@formbricks/ui";
import { XCircleIcon } from "@heroicons/react/24/solid";
import { signIn } from "next-auth/react";
@@ -75,7 +76,7 @@ export const SigninForm = () => {
className="focus:border-brand focus:ring-brand block w-full rounded-md border-slate-300 shadow-sm sm:text-sm"
/>
</div>
{process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
{env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
<Link
href="/auth/forgot-password"
@@ -105,18 +106,18 @@ export const SigninForm = () => {
</Button>
</form>
{process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
{env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
<>
<GoogleButton />
</>
)}
{process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
{env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
<>
<GithubButton />
</>
)}
</div>
{process.env.NEXT_PUBLIC_SIGNUP_DISABLED !== "1" && (
{env.NEXT_PUBLIC_SIGNUP_DISABLED !== "1" && (
<div className="mt-9 text-center text-xs ">
<span className="leading-5 text-slate-500">New to Formbricks?</span>
<br />

View File

@@ -1,15 +1,15 @@
"use client";
import { Button } from "@formbricks/ui";
import { PasswordInput } from "@formbricks/ui";
import { GoogleButton } from "@/components/auth/GoogleButton";
import IsPasswordValid from "@/components/auth/IsPasswordValid";
import { env } from "@/env.mjs";
import { createUser } from "@/lib/users/users";
import { Button, PasswordInput } from "@formbricks/ui";
import { XCircleIcon } from "@heroicons/react/24/solid";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useRef, useState } from "react";
import { GithubButton } from "./GithubButton";
import { GoogleButton } from "@/components/auth/GoogleButton";
import IsPasswordValid from "@/components/auth/IsPasswordValid";
export const SignupForm = () => {
const searchParams = useSearchParams();
@@ -33,7 +33,7 @@ export const SignupForm = () => {
searchParams?.get("inviteToken")
);
const url =
process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED === "1"
env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED === "1"
? `/auth/signup-without-verification-success`
: `/auth/verification-requested?email=${encodeURIComponent(e.target.elements.email.value)}`;
@@ -131,7 +131,7 @@ export const SignupForm = () => {
className="focus:border-brand focus:ring-brand block w-full rounded-md shadow-sm sm:text-sm"
/>
</div>
{process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
{env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED !== "1" && isPasswordFocused && (
<div className="ml-1 text-right transition-all duration-500 ease-in-out">
<Link
href="/auth/forgot-password"
@@ -163,36 +163,36 @@ export const SignupForm = () => {
</Button>
</form>
{process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
{env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED === "1" && (
<>
<GoogleButton />
</>
)}
{process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
{env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED === "1" && (
<>
<GithubButton />{" "}
</>
)}
</div>
{(process.env.NEXT_PUBLIC_TERMS_URL || process.env.NEXT_PUBLIC_PRIVACY_URL) && (
{(env.NEXT_PUBLIC_TERMS_URL || env.NEXT_PUBLIC_PRIVACY_URL) && (
<div className="mt-3 text-center text-xs text-slate-500">
By signing up, you agree to our
<br />
{process.env.NEXT_PUBLIC_TERMS_URL && (
{env.NEXT_PUBLIC_TERMS_URL && (
<Link
className="font-semibold"
href="google.com" /* {process.env.NEXT_PUBLIC_TERMS_URL} */
href="google.com" /* {env.NEXT_PUBLIC_TERMS_URL} */
rel="noreferrer"
target="_blank">
Terms of Service
</Link>
)}
{process.env.NEXT_PUBLIC_TERMS_URL && process.env.NEXT_PUBLIC_PRIVACY_URL && <span> and </span>}
{process.env.NEXT_PUBLIC_PRIVACY_URL && (
{env.NEXT_PUBLIC_TERMS_URL && env.NEXT_PUBLIC_PRIVACY_URL && <span> and </span>}
{env.NEXT_PUBLIC_PRIVACY_URL && (
<Link
className="font-semibold"
href="google.com" /* {/* process.env.NEXT_PUBLIC_PRIVACY_URL }*/
href="google.com" /* {/* env.NEXT_PUBLIC_PRIVACY_URL }*/
rel="noreferrer"
target="_blank">
Privacy Policy.

View File

@@ -2,7 +2,7 @@ import { Input } from "@/../../packages/ui";
import SubmitButton from "@/components/preview/SubmitButton";
import { cn } from "@formbricks/lib/cn";
import type { MultipleChoiceSingleQuestion } from "@formbricks/types/questions";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import Headline from "./Headline";
import Subheader from "./Subheader";
@@ -20,6 +20,13 @@ export default function MultipleChoiceSingleQuestion({
brandColor,
}: MultipleChoiceSingleProps) {
const [selectedChoice, setSelectedChoice] = useState<string | null>(null);
const otherSpecify = useRef<HTMLInputElement>(null);
useEffect(() => {
if (selectedChoice === "other") {
otherSpecify.current?.focus();
}
}, [selectedChoice]);
/* const [isIphone, setIsIphone] = useState(false);
@@ -31,7 +38,7 @@ export default function MultipleChoiceSingleQuestion({
<form
onSubmit={(e) => {
e.preventDefault();
const value = e.currentTarget[question.id].value;
const value = otherSpecify.current?.value || e.currentTarget[question.id].value;
const data = {
[question.id]: value,
};
@@ -72,6 +79,7 @@ export default function MultipleChoiceSingleQuestion({
{choice.id === "other" && selectedChoice === "other" && (
<Input
id={`${choice.id}-label`}
ref={otherSpecify}
name={question.id}
placeholder="Please specify"
className="mt-3 bg-white focus:border-slate-300"

View File

@@ -1,18 +1,19 @@
import { env } from "@/env.mjs";
import Link from "next/link";
export default function LegalFooter() {
if (!process.env.NEXT_PUBLIC_IMPRINT_URL && !process.env.NEXT_PUBLIC_PRIVACY_URL) return null;
if (!env.NEXT_PUBLIC_IMPRINT_URL && !env.NEXT_PUBLIC_PRIVACY_URL) return null;
return (
<div className="top-0 z-10 w-full border-b bg-white">
<div className="mx-auto max-w-lg p-3 text-center text-sm text-slate-400">
{process.env.NEXT_PUBLIC_IMPRINT_URL && (
<Link href={process.env.NEXT_PUBLIC_IMPRINT_URL} target="_blank">
{env.NEXT_PUBLIC_IMPRINT_URL && (
<Link href={env.NEXT_PUBLIC_IMPRINT_URL} target="_blank">
Imprint
</Link>
)}
{process.env.NEXT_PUBLIC_IMPRINT_URL && process.env.NEXT_PUBLIC_PRIVACY_URL && <span> | </span>}
{process.env.NEXT_PUBLIC_PRIVACY_URL && (
<Link href={process.env.NEXT_PUBLIC_PRIVACY_URL} target="_blank">
{env.NEXT_PUBLIC_IMPRINT_URL && env.NEXT_PUBLIC_PRIVACY_URL && <span> | </span>}
{env.NEXT_PUBLIC_PRIVACY_URL && (
<Link href={env.NEXT_PUBLIC_PRIVACY_URL} target="_blank">
Privacy Policy
</Link>
)}

110
apps/web/env.mjs Normal file
View File

@@ -0,0 +1,110 @@
import { createEnv } from "@t3-oss/env-nextjs";
import { z } from "zod";
export const env = createEnv({
/*
* Serverside Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
DATABASE_URL: z.string().url(),
PRISMA_GENERATE_DATAPROXY: z.enum(["true", ""]).optional(),
NEXTAUTH_SECRET: z.string().min(1),
NEXTAUTH_URL: z.string().url().optional(),
MAIL_FROM: z.string().email().optional(),
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.string().min(1).optional(),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASSWORD: z.string().min(1).optional(),
SMTP_SECURE_ENABLED: z.enum(["1", "0"]).optional(),
GITHUB_ID: z.string().optional(),
GITHUB_SECRET: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(),
GOOGLE_CLIENT_SECRET: z.string().optional(),
STRIPE_SECRET_KEY: z.string().optional(),
STRIPE_WEBHOOK_SECRET: z.string().optional(),
CRON_SECRET: z.string().optional(),
NEXT_PUBLIC_POSTHOG_API_KEY: z.string().optional(),
NEXT_PUBLIC_POSTHOG_API_HOST: z.string().optional(),
},
/*
* Environment variables available on the client (and server).
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {
NEXT_PUBLIC_WEBAPP_URL: z.string().url().optional(),
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_PASSWORD_RESET_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_SIGNUP_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_INVITE_DISABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_PRIVACY_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_TERMS_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_IMPRINT_URL: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_GITHUB_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED: z.enum(["1", "0"]).optional(),
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: z.string().optional(),
NEXT_PUBLIC_FORMBRICKS_API_HOST: z
.string()
.url()
.optional()
.or(z.string().refine((str) => str === "")),
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: z.string().optional(),
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: z.string().optional(),
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: z.enum(["1", "0"]).optional(),
},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
DATABASE_URL: process.env.DATABASE_URL,
PRISMA_GENERATE_DATAPROXY: process.env.PRISMA_GENERATE_DATAPROXY,
NEXTAUTH_SECRET: process.env.NEXTAUTH_SECRET,
NEXTAUTH_URL: process.env.NEXTAUTH_URL,
MAIL_FROM: process.env.MAIL_FROM,
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_PASSWORD: process.env.SMTP_PASSWORD,
SMTP_SECURE_ENABLED: process.env.SMTP_SECURE_ENABLED,
GITHUB_ID: process.env.GITHUB_ID,
GITHUB_SECRET: process.env.GITHUB_SECRET,
GOOGLE_CLIENT_ID: process.env.GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET: process.env.GOOGLE_CLIENT_SECRET,
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
CRON_SECRET: process.env.CRON_SECRET,
NEXT_PUBLIC_WEBAPP_URL: process.env.NEXT_PUBLIC_WEBAPP_URL,
NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED: process.env.NEXT_PUBLIC_EMAIL_VERIFICATION_DISABLED,
NEXT_PUBLIC_PASSWORD_RESET_DISABLED: process.env.NEXT_PUBLIC_PASSWORD_RESET_DISABLED,
NEXT_PUBLIC_SIGNUP_DISABLED: process.env.NEXT_PUBLIC_SIGNUP_DISABLED,
NEXT_PUBLIC_INVITE_DISABLED: process.env.NEXT_PUBLIC_INVITE_DISABLED,
NEXT_PUBLIC_PRIVACY_URL: process.env.NEXT_PUBLIC_PRIVACY_URL,
NEXT_PUBLIC_TERMS_URL: process.env.NEXT_PUBLIC_TERMS_URL,
NEXT_PUBLIC_IMPRINT_URL: process.env.NEXT_PUBLIC_IMPRINT_URL,
NEXT_PUBLIC_GITHUB_AUTH_ENABLED: process.env.NEXT_PUBLIC_GITHUB_AUTH_ENABLED,
NEXT_PUBLIC_GOOGLE_AUTH_ENABLED: process.env.NEXT_PUBLIC_GOOGLE_AUTH_ENABLED,
NEXT_PUBLIC_STRIPE_PUBLIC_KEY: process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY,
NEXT_PUBLIC_FORMBRICKS_API_HOST: process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST,
NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID,
NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID: process.env.NEXT_PUBLIC_FORMBRICKS_ONBOARDING_SURVEY_ID,
NEXT_PUBLIC_IS_FORMBRICKS_CLOUD: process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD,
NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY,
NEXT_PUBLIC_POSTHOG_API_HOST: process.env.NEXT_PUBLIC_POSTHOG_API_HOST,
},
});

View File

@@ -1,3 +1,4 @@
import { env } from "@/env.mjs";
import { getQuestionResponseMapping } from "@/lib/responses/questionResponseMapping";
import { WEBAPP_URL } from "@formbricks/lib/constants";
import { Question } from "@formbricks/types/questions";
@@ -16,20 +17,20 @@ interface sendEmailData {
html: string;
}
const sendEmail = async (emailData: sendEmailData) => {
export const sendEmail = async (emailData: sendEmailData) => {
let transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE_ENABLED === "1", // true for 465, false for other ports
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE_ENABLED === "1", // true for 465, false for other ports
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
user: env.SMTP_USER,
pass: env.SMTP_PASSWORD,
},
// logger: true,
// debug: true,
});
const emailDefaults = {
from: `Formbricks <${process.env.MAIL_FROM || "noreply@formbricks.com"}>`,
from: `Formbricks <${env.MAIL_FROM || "noreply@formbricks.com"}>`,
};
await transporter.sendMail({ ...emailDefaults, ...emailData });
};
@@ -128,7 +129,7 @@ export const sendResponseFinishedEmail = async (
subject: personEmail
? `${personEmail} just completed your ${survey.name} survey ✅`
: `A response for ${survey.name} was completed ✅`,
replyTo: personEmail || process.env.MAIL_FROM,
replyTo: personEmail || env.MAIL_FROM,
html: withEmailTemplate(`<h1>Survey completed</h1>Someone just completed your survey "${survey.name}"<br/>
<hr/>

View File

@@ -1,13 +1,14 @@
import formbricks, { PersonId, SurveyId, ResponseId } from "@formbricks/js";
import { env } from "@/env.mjs";
export const formbricksEnabled =
typeof process.env.NEXT_PUBLIC_FORMBRICKS_API_HOST && process.env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
typeof env.NEXT_PUBLIC_FORMBRICKS_API_HOST && env.NEXT_PUBLIC_FORMBRICKS_ENVIRONMENT_ID;
export const createResponse = async (
surveyId: SurveyId,
data: { [questionId: string]: any },
finished: boolean = false
) => {
): Promise<any> => {
const api = formbricks.getApi();
const personId = formbricks.getPerson()?.id as PersonId;
return await api.createResponse({
@@ -22,7 +23,7 @@ export const updateResponse = async (
responseId: ResponseId,
data: { [questionId: string]: any },
finished: boolean = false
) => {
): Promise<any> => {
const api = formbricks.getApi();
return await api.updateResponse({
responseId,

View File

@@ -1,8 +1,9 @@
import jwt from "jsonwebtoken";
import { prisma } from "@formbricks/database";
import { env } from "@/env.mjs";
export function createToken(userId, userEmail, options = {}) {
return jwt.sign({ id: userId }, process.env.NEXTAUTH_SECRET + userEmail, options);
return jwt.sign({ id: userId }, env.NEXTAUTH_SECRET + userEmail, options);
}
export async function verifyToken(token, userEmail = "") {
@@ -20,11 +21,11 @@ export async function verifyToken(token, userEmail = "") {
userEmail = foundUser.email;
}
return jwt.verify(token, process.env.NEXTAUTH_SECRET + userEmail);
return jwt.verify(token, env.NEXTAUTH_SECRET + userEmail);
}
export const createInviteToken = (inviteId: string, email: string, options = {}) => {
return jwt.sign({ inviteId, email }, process.env.NEXTAUTH_SECRET, options);
return jwt.sign({ inviteId, email }, env.NEXTAUTH_SECRET, options);
};
export const verifyInviteToken = async (token: string) => {

View File

@@ -22,7 +22,7 @@ export const useLinkSurvey = (surveyId: string) => {
export const useLinkSurveyUtils = (survey: Survey) => {
const [currentQuestion, setCurrentQuestion] = useState<Question | null>(null);
const [prefilling, setPrefilling] = useState(false);
const [prefilling, setPrefilling] = useState(true);
const [progress, setProgress] = useState(0); // [0, 1]
const [finished, setFinished] = useState(false);
const [loadingElement, setLoadingElement] = useState(false);
@@ -49,13 +49,11 @@ export const useLinkSurveyUtils = (survey: Survey) => {
if (isPreview) return;
// create display
createDisplay(
{ surveyId: survey.id },
`${window.location.protocol}//${window.location.host}`,
survey.environmentId
).then((display) => {
setDisplayId(display.id);
});
createDisplay({ surveyId: survey.id }, `${window.location.protocol}//${window.location.host}`).then(
(display) => {
setDisplayId(display.id);
}
);
}
}
}, [survey, isPreview, isLoadingPerson]);
@@ -117,11 +115,7 @@ export const useLinkSurveyUtils = (survey: Survey) => {
`${window.location.protocol}//${window.location.host}`
);
if (displayId) {
markDisplayResponded(
displayId,
`${window.location.protocol}//${window.location.host}`,
survey.environmentId
);
markDisplayResponded(displayId, `${window.location.protocol}//${window.location.host}`);
}
setResponseId(response.id);
} else if (responseId && !isPreview) {
@@ -166,7 +160,6 @@ export const useLinkSurveyUtils = (survey: Survey) => {
if (!currentQuestion) return;
const firstQuestionId = survey.questions[0].id;
if (currentQuestion.id !== firstQuestionId) return;
setPrefilling(true);
const question = survey.questions.find((q) => q.id === firstQuestionId);
if (!question) throw new Error("Question not found");
@@ -239,6 +232,11 @@ const checkValidity = (question: Question, answer: any): boolean => {
if (answer !== "clicked" && answer !== "dismissed") return false;
return true;
}
case QuestionType.Consent: {
if (question.required && answer === "dismissed") return false;
if (answer !== "accepted" && answer !== "dismissed") return false;
return true;
}
case QuestionType.Rating: {
answer = answer.replace(/&/g, ";");
const answerNumber = Number(JSON.parse(answer));
@@ -257,6 +255,7 @@ const createAnswer = (question: Question, answer: string): string | number | str
switch (question.type) {
case QuestionType.OpenText:
case QuestionType.MultipleChoiceSingle:
case QuestionType.Consent:
case QuestionType.CTA: {
return answer;
}

View File

@@ -1,9 +1,8 @@
import "./env.mjs";
import { createId } from "@paralleldrive/cuid2";
/** @type {import('next').NextConfig} */
require("@next/env").loadEnvConfig("../../");
const { createId } = require("@paralleldrive/cuid2");
const nextConfig = {
output: "standalone",
experimental: {
@@ -66,4 +65,4 @@ const nextConfig = {
},
};
module.exports = nextConfig;
export default nextConfig;

View File

@@ -21,10 +21,10 @@
"@headlessui/react": "^1.7.15",
"@heroicons/react": "^2.0.18",
"@json2csv/node": "^7.0.1",
"@next/env": "^13.4.8",
"@paralleldrive/cuid2": "^2.2.1",
"@radix-ui/react-collapsible": "^1.0.3",
"@radix-ui/react-dropdown-menu": "^2.0.5",
"@t3-oss/env-nextjs": "^0.6.0",
"bcryptjs": "^2.4.3",
"eslint-config-next": "^13.4.8",
"jsonwebtoken": "^9.0.0",

View File

@@ -1,3 +1,4 @@
import { env } from "@/env.mjs";
import { getPlan, hasEnvironmentAccess } from "@/lib/api/apiHelper";
import { prisma } from "@formbricks/database";
import { RESPONSES_LIMIT_FREE } from "@formbricks/lib/constants";
@@ -71,7 +72,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
},
});
if (process.env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD === "1") {
if (env.NEXT_PUBLIC_IS_FORMBRICKS_CLOUD === "1") {
const plan = await getPlan(req, res);
if (plan === "free" && responses.length > RESPONSES_LIMIT_FREE) {
return res.json({

View File

@@ -1,3 +1,4 @@
import { env } from "@/env.mjs";
import { getSessionUser, hasTeamAccess, isAdminOrOwner } from "@/lib/api/apiHelper";
import { sendInviteMemberEmail } from "@/lib/email";
import { prisma } from "@formbricks/database";
@@ -20,7 +21,7 @@ export default async function handle(req: NextApiRequest, res: NextApiResponse)
return res.status(403).json({ message: "Not authorized" });
}
if (process.env.NEXT_PUBLIC_INVITE_DISABLED === "1") {
if (env.NEXT_PUBLIC_INVITE_DISABLED === "1") {
return res.status(403).json({ message: "Invite Disabled" });
}

View File

@@ -17,7 +17,7 @@
"db:migrate:deploy": "turbo run db:migrate:deploy",
"db:migrate:vercel": "turbo run db:migrate:vercel",
"db:push": "turbo run db:push",
"dev": "turbo run dev --parallel --filter=!formbricks-com",
"dev": "turbo run dev --parallel --filter=web... --filter=demo...",
"start": "turbo run start --parallel",
"format": "prettier --write \"**/*.{ts,tsx,md}\"",
"generate": "turbo run generate",

View File

@@ -1,11 +1,12 @@
import type { DisplayCreateRequest, JsConfig, Response } from "../../../types/js";
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
import type { JsConfig } from "../../../types/js";
import { NetworkError, Result, err, ok, okVoid } from "./errors";
export const createDisplay = async (
displayCreateRequest: DisplayCreateRequest,
displayCreateRequest: TDisplayInput,
config: JsConfig
): Promise<Result<Response, NetworkError>> => {
const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays`;
): Promise<Result<TDisplay, NetworkError>> => {
const url = `${config.apiHost}/api/v1/client/displays`;
const res = await fetch(url, {
method: "POST",
@@ -14,41 +15,38 @@ export const createDisplay = async (
});
if (!res.ok) {
const jsonRes = await res.json();
return err({
code: "network_error",
message: "Could not create display",
status: res.status,
url,
responseMessage: jsonRes.message,
responseMessage: await res.text(),
});
}
const response = (await res.json()) as Response;
const jsonRes = await res.json();
return ok(response);
return ok(jsonRes.data as TDisplay);
};
export const markDisplayResponded = async (
displayId: string,
config: JsConfig
): Promise<Result<void, NetworkError>> => {
const url = `${config.apiHost}/api/v1/client/environments/${config.environmentId}/displays/${displayId}/responded`;
const url = `${config.apiHost}/api/v1/client/displays/${displayId}/responded`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const jsonRes = await res.json();
if (!res.ok) {
return err({
code: "network_error",
message: "Could not mark display as responded",
status: res.status,
url,
responseMessage: jsonRes.message,
responseMessage: await res.text(),
});
}

View File

@@ -1,11 +1,10 @@
import { DisplayCreateRequest } from "@formbricks/types/js";
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
export const createDisplay = async (
displayCreateRequest: DisplayCreateRequest,
apiHost: string,
environmentId: string
): Promise<{ id: string }> => {
const res = await fetch(`${apiHost}/api/v1/client/environments/${environmentId}/displays`, {
displayCreateRequest: TDisplayInput,
apiHost: string
): Promise<TDisplay> => {
const res = await fetch(`${apiHost}/api/v1/client/displays`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(displayCreateRequest),
@@ -14,21 +13,15 @@ export const createDisplay = async (
console.error(res.text);
throw new Error("Could not create display");
}
return await res.json();
const resJson = await res.json();
return resJson.data;
};
export const markDisplayResponded = async (
displayId: string,
apiHost: string,
environmentId: string
): Promise<void> => {
const res = await fetch(
`${apiHost}/api/v1/client/environments/${environmentId}/displays/${displayId}/responded`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
}
);
export const markDisplayResponded = async (displayId: string, apiHost: string): Promise<void> => {
const res = await fetch(`${apiHost}/api/v1/client/displays/${displayId}/responded`, {
method: "POST",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
throw new Error("Could not update display");
}

View File

@@ -16,3 +16,4 @@ export const WEBAPP_URL =
// Other
export const INTERNAL_SECRET = process.env.INTERNAL_SECRET;
export const CRON_SECRET = process.env.CRON_SECRET;

View File

@@ -0,0 +1,100 @@
import { prisma } from "@formbricks/database";
import { TDisplay, TDisplayInput } from "@formbricks/types/v1/displays";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
import { transformPrismaPerson } from "./person";
const selectDisplay = {
id: true,
createdAt: true,
updatedAt: true,
surveyId: true,
person: {
select: {
id: true,
createdAt: true,
updatedAt: true,
attributes: {
select: {
value: true,
attributeClass: {
select: {
name: true,
},
},
},
},
},
},
status: true,
};
export const createDisplay = async (displayInput: TDisplayInput): Promise<TDisplay> => {
try {
const displayPrisma = await prisma.display.create({
data: {
survey: {
connect: {
id: displayInput.surveyId,
},
},
status: "seen",
...(displayInput.personId && {
person: {
connect: {
id: displayInput.personId,
},
},
}),
},
select: selectDisplay,
});
const display: TDisplay = {
...displayPrisma,
person: transformPrismaPerson(displayPrisma.person),
};
return display;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};
export const markDisplayResponded = async (displayId: string): Promise<TDisplay> => {
try {
if (!displayId) throw new Error("Display ID is required");
const displayPrisma = await prisma.display.update({
where: {
id: displayId,
},
data: {
status: "responded",
},
select: selectDisplay,
});
if (!displayPrisma) {
throw new ResourceNotFoundError("Display", displayId);
}
const display: TDisplay = {
...displayPrisma,
person: transformPrismaPerson(displayPrisma.person),
};
return display;
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -37,7 +37,7 @@ export const transformPrismaPerson = (person: TransformPersonInput | null): Tran
id: person.id,
attributes: attributes,
createdAt: person.createdAt,
updatedAt: person.updatedAt
updatedAt: person.updatedAt,
};
};

View File

@@ -0,0 +1,50 @@
import { prisma } from "@formbricks/database";
import { Prisma } from "@prisma/client";
import { DatabaseError, ResourceNotFoundError } from "@formbricks/errors";
export const getTeamDetails = async (environmentId: string) => {
try {
const environment = await prisma.environment.findUnique({
where: {
id: environmentId,
},
select: {
product: {
select: {
team: {
select: {
id: true,
memberships: {
select: {
userId: true,
role: true,
},
},
},
},
},
},
},
});
if (!environment) {
throw new ResourceNotFoundError("Environment", environmentId);
}
const teamId: string = environment.product.team.id;
// find team owner
const teamOwnerId: string | undefined = environment.product.team.memberships.find(
(m) => m.role === "owner"
)?.userId;
return {
teamId: teamId,
teamOwnerId: teamOwnerId,
};
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
throw new DatabaseError("Database operation failed");
}
throw error;
}
};

View File

@@ -33,3 +33,15 @@ export interface AttributeFilter {
condition: string;
value: string;
}
export interface SurveyNotificationData {
id: string;
numDisplays: number;
numDisplaysResponded: number;
responseLenght: number;
responseCompletedLength: number;
latestResponse: any;
questions: Question[];
status: "draft" | "inProgress" | "archived" | "paused" | "completed";
name: String;
}

View File

@@ -1,6 +1,8 @@
export interface NotificationSettings {
[surveyId: string]: {
responseFinished: boolean;
weeklySummary: boolean;
alert: {
[surveyId: string]: boolean;
};
weeklySummary: {
[productId: string]: boolean;
};
}

View File

@@ -0,0 +1,20 @@
import { z } from "zod";
import { ZPerson } from "./people";
export const ZDisplay = z.object({
id: z.string().cuid2(),
createdAt: z.date(),
updatedAt: z.date(),
surveyId: z.string().cuid2(),
person: ZPerson.nullable(),
status: z.enum(["seen", "responded"]),
});
export type TDisplay = z.infer<typeof ZDisplay>;
export const ZDisplayInput = z.object({
surveyId: z.string().cuid2(),
personId: z.string().cuid2().optional(),
});
export type TDisplayInput = z.infer<typeof ZDisplayInput>;

View File

@@ -7,7 +7,7 @@ export const ZPerson = z.object({
id: z.string().cuid2(),
attributes: ZPersonAttributes,
createdAt: z.date(),
updatedAt: z.date()
updatedAt: z.date(),
});
export type TPerson = z.infer<typeof ZPerson>;

View File

@@ -204,7 +204,7 @@ export const ZSurvey = z.object({
questions: ZSurveyQuestions,
thankYouCard: ZSurveyThankYouCard,
delay: z.number(),
autoComplete: z.union([z.boolean(), z.null()]),
autoComplete: z.union([z.number(), z.null()]),
analytics: z.object({
numDisplays: z.number(),
responseRate: z.number(),

View File

@@ -1,10 +1,8 @@
import { z } from "zod";
export const ZUserNotificationSettings = z.record(
z.object({
responseFinished: z.boolean(),
weeklySummary: z.boolean(),
})
);
export const ZUserNotificationSettings = z.object({
alert: z.record(z.boolean()),
weeklySummary: z.record(z.boolean()),
});
export type TUserNotificationSettings = z.infer<typeof ZUserNotificationSettings>;

35
pnpm-lock.yaml generated
View File

@@ -1,5 +1,9 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
@@ -162,9 +166,6 @@ importers:
'@json2csv/node':
specifier: ^7.0.1
version: 7.0.1
'@next/env':
specifier: ^13.4.8
version: 13.4.8
'@paralleldrive/cuid2':
specifier: ^2.2.1
version: 2.2.1
@@ -174,6 +175,9 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.0.5
version: 2.0.5(react-dom@18.2.0)(react@18.2.0)
'@t3-oss/env-nextjs':
specifier: ^0.6.0
version: 0.6.0(typescript@5.1.6)(zod@3.21.4)
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
@@ -5149,6 +5153,27 @@ packages:
dependencies:
defer-to-connect: 1.1.3
/@t3-oss/env-core@0.6.0(typescript@5.1.6)(zod@3.21.4):
resolution: {integrity: sha512-3FkPAba069WRZVVab/sB1m3eSGn/rZeypx5k+sWEu1d+k0OQdRDnvFS+7MtxYgqVrwaRk3b7yVnX2dgSPVmWPQ==}
peerDependencies:
typescript: '>=4.7.2'
zod: ^3.0.0
dependencies:
typescript: 5.1.6
zod: 3.21.4
dev: false
/@t3-oss/env-nextjs@0.6.0(typescript@5.1.6)(zod@3.21.4):
resolution: {integrity: sha512-SpzcGNIbUYcQw4zPPFeRJqCC1560zL7QmB0puIqOnuCsmykPkqHPX+n9CNZLXVQerboHzfvb7Kd+jAdouk72Vw==}
peerDependencies:
typescript: '>=4.7.2'
zod: ^3.0.0
dependencies:
'@t3-oss/env-core': 0.6.0(typescript@5.1.6)(zod@3.21.4)
typescript: 5.1.6
zod: 3.21.4
dev: false
/@tailwindcss/forms@0.5.3(tailwindcss@3.3.2):
resolution: {integrity: sha512-y5mb86JUoiUgBjY/o6FJSFZSEttfb3Q5gllE4xoKjAAD+vBrnIhE4dViwUuow3va8mpH4s9jyUbUbrRGoRdc2Q==}
peerDependencies:
@@ -20726,7 +20751,3 @@ packages:
/zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
dev: false
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false

View File

@@ -5,6 +5,7 @@
"dependsOn": ["^build"],
"outputs": ["dist/**", ".next/**"],
"env": [
"CRON_SECRET",
"GITHUB_ID",
"GITHUB_SECRET",
"GOOGLE_CLIENT_ID",
@@ -55,6 +56,8 @@
"RAILWAY_STATIC_URL",
"RENDER_EXTERNAL_URL",
"SENTRY_DSN",
"STRIPE_SECRET_KEY",
"STRIPE_WEBHOOK_SECRET",
"TELEMETRY_DISABLED",
"VERCEL_URL"
]