diff --git a/package.json b/package.json index 9d19ad29..80828dc8 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "json-2-csv": "^5.5.7", "lucide-react": "^0.428.0", "mailgun.js": "^10.3.0", + "marked": "^15.0.6", "next": "14.2.10", "next-themes": "^0.3.0", "path": "^0.12.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7c14e6c..a62fd8b8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -89,6 +89,9 @@ importers: mailgun.js: specifier: ^10.3.0 version: 10.3.0 + marked: + specifier: ^15.0.6 + version: 15.0.6 next: specifier: 14.2.10 version: 14.2.10(@babel/core@7.26.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -3248,6 +3251,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@15.0.6: + resolution: {integrity: sha512-Y07CUOE+HQXbVDCGl3LXggqJDbXDP2pArc2C1N1RRMN0ONiShoSsIInMd5Gsxupe7fKLpgimTV+HOJ9r7bA+pg==} + engines: {node: '>= 18'} + hasBin: true + marked@7.0.4: resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==} engines: {node: '>= 16'} @@ -7923,6 +7931,8 @@ snapshots: markdown-table@3.0.4: {} + marked@15.0.6: {} + marked@7.0.4: {} math-intrinsics@1.1.0: {} diff --git a/src/app/globals.css b/src/app/globals.css index 5da12e3d..6b021a2e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -226,3 +226,25 @@ tbody { -ms-overflow-style: none; scrollbar-width: none; } + +/* For Chrome, Safari, and newer versions of Edge */ +.scrollbar { + scrollbar-width: thin; /* For Firefox */ + @apply overflow-x-auto; +} + +.scrollbar::-webkit-scrollbar { + @apply h-1.5; /* height for horizontal scrollbar */ +} + +.scrollbar::-webkit-scrollbar-track { + @apply bg-background-800 rounded-full; +} + +.scrollbar::-webkit-scrollbar-thumb { + @apply bg-background-600 rounded-full; +} + +.scrollbar::-webkit-scrollbar-thumb:hover { + @apply bg-background-500; +} diff --git a/src/app/portal/admin/actions.ts b/src/app/portal/admin/actions.ts index a22ae865..a1288f59 100644 --- a/src/app/portal/admin/actions.ts +++ b/src/app/portal/admin/actions.ts @@ -1,10 +1,11 @@ "use server"; import { db } from "@/db"; import { FormStep } from "@/lib/types/questions"; -import { FormFields } from "@/app/portal/admin/forms/[id]/submissions/columns"; +import { FormFields } from "@/components/forms/applications/columns"; import { sendEmail } from "@/lib/utils/forms/email"; import { MarkdownTemplate } from "@/components/forms/emailTemplates/markdownTemplate"; import { render } from "@react-email/components"; +import { object } from "zod"; export async function getForms() { return db.forms.findMany(); @@ -99,6 +100,7 @@ export async function getAllFormDetails( formId: bigint, ): Promise<{ rawForm: any; formFields: FormFields; submissions: any[] }> { try { + console.log("retrieving form details"); const form = await db.forms.findFirst({ where: { id: formId }, }); diff --git a/src/app/portal/admin/forms/[id]/layout.tsx b/src/app/portal/admin/forms/[id]/layout.tsx index 5dee4b14..ed75de80 100644 --- a/src/app/portal/admin/forms/[id]/layout.tsx +++ b/src/app/portal/admin/forms/[id]/layout.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { Suspense } from "react"; import FormTabView, { Tab } from "@/components/layouts/formTabView"; import { getAdminMembers, getAllFormDetails } from "@/app/portal/admin/actions"; @@ -21,11 +21,15 @@ export default async function Layout({ label: "Support", route: `/portal/admin/forms/${params.id}/support`, }, + { + label: "Settings", + route: `/portal/admin/forms/${params.id}/settings`, + }, ]; return ( - {children} + Loading...}>{children} ); } diff --git a/src/app/portal/admin/forms/[id]/settings/actions.ts b/src/app/portal/admin/forms/[id]/settings/actions.ts new file mode 100644 index 00000000..49bd39da --- /dev/null +++ b/src/app/portal/admin/forms/[id]/settings/actions.ts @@ -0,0 +1,55 @@ +"use server"; +import { db } from "@/db"; + +type EmailTemplate = { + title: string; + content: string; +}; + +export async function updateOrCreateEmailTemplate( + formId: number, + status: string, + template: EmailTemplate, +) { + try { + // First get the current form to access existing config + const form = await db.forms.findFirst({ + where: { id: BigInt(formId) }, + }); + + if (!form) { + throw new Error(`Form with id ${formId} not found`); + } + + // Get existing config or initialize if doesn't exist + const currentConfig = (form.config as Record) || {}; + + // Safely create nested structure if it doesn't exist + const updatedConfig = { + ...currentConfig, + application: { + ...currentConfig.application, + emails: { + ...(currentConfig.application?.emails || {}), + status: { + ...(currentConfig.application?.emails?.status || {}), + [status]: template, + }, + }, + }, + }; + + // Update only the config field + await db.forms.update({ + where: { id: BigInt(formId) }, + data: { + config: updatedConfig, + }, + }); + + return { success: true }; + } catch (error) { + console.error("Error updating email template:", error); + throw error; + } +} diff --git a/src/app/portal/admin/forms/[id]/settings/page.tsx b/src/app/portal/admin/forms/[id]/settings/page.tsx index b6e7424e..38a444fa 100644 --- a/src/app/portal/admin/forms/[id]/settings/page.tsx +++ b/src/app/portal/admin/forms/[id]/settings/page.tsx @@ -1,14 +1,480 @@ +"use client"; +import { useState, useMemo } from "react"; +import { marked } from "marked"; +import Input from "@/components/general/input"; +import { formContext } from "@/components/layouts/formTabView"; +import { Textarea } from "@/components/primitives/textArea"; +import { useContext } from "react"; +import { Button } from "@/components/primitives/button"; +import MultiSelect from "@/components/general/multiSelect"; +import { FormStep } from "@/lib/types/questions"; +import { updateOrCreateEmailTemplate } from "./actions"; +import { toast } from "sonner"; +import { Dialog } from "@/components/primitives/dialog"; +import { number } from "zod"; + +// Helper function to extract template tags +const extractTemplateTags = (content: string): string[] => { + const regex = /{{([^{}]+)}}/g; + const matches = content.match(regex); + if (!matches) return []; + return matches.map((match) => match.slice(2, -2)); +}; + +// Helper to find unclosed tags +const findInvalidTags = (content: string): string[] => { + const regex = /{{([^{}]*$)|{{([^{}]*)}(?!})/g; + const matches = content.match(regex); + return matches || []; +}; + export default function FormSettingsPage() { + const { rawForm: form, submissions } = useContext(formContext); + const emailStatuses = form?.config.application?.emails?.status || {}; + const statusOptions = form?.config.application?.status || []; + const [editingStatus, setEditingStatus] = useState(null); + const [showNewTemplate, setShowNewTemplate] = useState(false); + const [selectedStatus, setSelectedStatus] = useState(""); + const [showPreview, setShowPreview] = useState(false); + + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [openTemplates, setOpenTemplates] = useState([]); + + async function applyTemplateChange(templateData: { + status: string; + title: string; + content: string; + }) { + try { + await updateOrCreateEmailTemplate(form.id, templateData.status, { + title: templateData.title, + content: templateData.content, + }); + toast.success("Template updated successfully", { + action: { + label: "Refresh", + onClick: () => window.location.reload(), + }, + }); + return true; + } catch (error) { + toast.error("Error updating template"); + console.error("Error updating template:", error); + return false; + } + } + + const templateFields = useMemo(() => { + const fields: Record = {}; + form.questions.forEach((fStep: FormStep) => { + fStep.questions.forEach((question) => { + fields[question.id] = question.label; + }); + }); + form.config.application?.subfields?.forEach( + (field: { id: string; label: string }) => { + fields[field.id] = field.label; + }, + ); + return fields; + }, [form]); + + const handleSave = async (templateData) => { + await applyTemplateChange(templateData); + setEditingStatus(null); + }; + + const handleCreateNew = async (templateData) => { + await applyTemplateChange(templateData); + setShowNewTemplate(false); + setSelectedStatus(""); + setTitle(""); + setContent(""); + }; + + const availableStatuses = statusOptions + .filter( + (status) => + !emailStatuses[status.id] || !emailStatuses[status.id.toString()], + ) + .map((status) => ({ + id: status.id, + label: status.label, + })); + return ( -
-
-
-

- Currently, apart from form questions, all other settings need to be - directly configured in the database. -

+
+
+
+
+
+

Email Settings

+ {availableStatuses.length > 0 && ( + + )} +
+
+ +
+ + Important Information About Email Templates + +
+
+

Email Sending Limits

+

+ Due to email service provider restrictions, we can only send + up to 100 automated emails per day. Please plan your bulk + notifications accordingly. +

+
+
+

Available Template Tags

+
+ {Object.entries(templateFields).map(([key, value]) => ( + + {`{{${key}}}`} + : {value} + + ))} +
+
+
+
+ +
+ ({ + value: status, + label: ( +
+ + {status} + + + {emailConfig.title} + +
+ ), + }), + )} + value={openTemplates} + onChange={setOpenTemplates} + allowMultiple={true} + className="w-full" + emptyText="Select templates to view..." + /> + {showNewTemplate && ( +
+

Create New Template

+
+
+

Status

+ +
+ +
+

Subject Line

+ setTitle(e.target.value)} + placeholder="Enter email subject" + className="w-full p-2 border rounded-md bg-background-600" + /> +
+ +
+

Email Content

+