feat: enhance email management with delete functionality and session handling
This commit is contained in:
parent
830b89d4b1
commit
d93d23c607
7 changed files with 168 additions and 55 deletions
|
|
@ -5,7 +5,7 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"d": "npm run-p dev stripe-dev -cl",
|
"d": "npm run-p dev stripe-dev -cl",
|
||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"dev-https": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' INTERNAL_BASE_URL='https://localhost:3000' next dev --experimental-https --turbopack",
|
"dev-https": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' INTERNAL_BASE_URL='https://localhost:3000' BASE_URL='https://localhost:3000' next dev --experimental-https --turbopack",
|
||||||
"email": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' email dev --port 3500",
|
"email": "cross-env NEXT_PUBLIC_BASE_URL='https://localhost:3000' email dev --port 3500",
|
||||||
"idev": "cross-env NODE_OPTIONS='--inspect' next dev --turbopack",
|
"idev": "cross-env NODE_OPTIONS='--inspect' next dev --turbopack",
|
||||||
"knip": "knip --no-exit-code --no-config-hints",
|
"knip": "knip --no-exit-code --no-config-hints",
|
||||||
|
|
|
||||||
|
|
@ -164,33 +164,41 @@ export const AreaRiservataLayoutUserView = ({
|
||||||
children: ReactNode & { props: { userId: UsersId } };
|
children: ReactNode & { props: { userId: UsersId } };
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const session = useSession();
|
||||||
const props = children.props as { userId: UsersId };
|
const props = children.props as { userId: UsersId };
|
||||||
|
if (session.status === "pending") {
|
||||||
|
return <LoadingPage />;
|
||||||
|
}
|
||||||
|
if (session.status === "error" || !session.user) {
|
||||||
|
window.location.reload();
|
||||||
|
return <LoadingPage />;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full min-h-screen flex-col">
|
<div className="flex h-full min-h-screen flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<main className="flex h-full flex-1 overflow-auto">
|
<main className="flex h-full flex-1 overflow-auto">
|
||||||
<UserViewProvider userId={props.userId}>
|
<UserViewProvider userId={props.userId}>
|
||||||
<Head>
|
<EnforcedSessionContext.Provider value={session as ValidSession}>
|
||||||
<title>{t.heads.area_riservata_titolo}</title>
|
<Head>
|
||||||
<meta
|
<title>{t.heads.area_riservata_titolo}</title>
|
||||||
content={t.heads.area_riservata_description}
|
<meta
|
||||||
name="description"
|
content={t.heads.area_riservata_description}
|
||||||
/>
|
name="description"
|
||||||
</Head>
|
/>
|
||||||
<div className="flex h-auto w-full flex-col md:flex-row">
|
</Head>
|
||||||
<Sidebar
|
<div className="flex h-auto w-full flex-col md:flex-row">
|
||||||
defaultOpen={getCookie(SIDEBAR_COOKIE_NAME) === "true"}
|
<Sidebar
|
||||||
isAdmin
|
defaultOpen={getCookie(SIDEBAR_COOKIE_NAME) === "true"}
|
||||||
/>
|
isAdmin
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="mx-4 my-3 flex-1 overflow-auto">
|
<div className="mx-4 my-3 flex-1 overflow-auto">
|
||||||
<UserViewHeader />
|
<UserViewHeader />
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</EnforcedSessionContext.Provider>
|
||||||
</UserViewProvider>
|
</UserViewProvider>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react";
|
import { type IframeHTMLAttributes, useState } from "react";
|
||||||
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
Accordion,
|
Accordion,
|
||||||
AccordionContent,
|
AccordionContent,
|
||||||
|
|
@ -8,56 +9,126 @@ import {
|
||||||
import { useMediaQuery } from "~/hooks/use-media-query";
|
import { useMediaQuery } from "~/hooks/use-media-query";
|
||||||
import type { UsersId } from "~/schemas/public/Users";
|
import type { UsersId } from "~/schemas/public/Users";
|
||||||
import { api } from "~/utils/api";
|
import { api } from "~/utils/api";
|
||||||
|
import { Button } from "../ui/button";
|
||||||
export const EmailAccordion = ({ userId }: { userId: UsersId }) => {
|
export const EmailAccordion = ({
|
||||||
|
userId,
|
||||||
|
isAdmin,
|
||||||
|
}: {
|
||||||
|
userId: UsersId;
|
||||||
|
isAdmin: boolean;
|
||||||
|
}) => {
|
||||||
const { data } = api.comunicazioni.getEmails.useQuery({
|
const { data } = api.comunicazioni.getEmails.useQuery({
|
||||||
userId,
|
userId,
|
||||||
});
|
});
|
||||||
|
const utils = api.useUtils();
|
||||||
|
const { mutate: deleteEmail } = api.comunicazioni.deleteEmail.useMutation({
|
||||||
|
onSuccess: async () => {
|
||||||
|
toast.success("Email eliminata con successo");
|
||||||
|
setOpenEmail("unset");
|
||||||
|
await utils.comunicazioni.getEmails.invalidate({ userId });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Track which accordion item is open
|
const [openEmail, setOpenEmail] = useState<string | "unset">("unset");
|
||||||
const [openIndex, setOpenIndex] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
const mobile = useMediaQuery("(max-width: 768px)");
|
const mobile = useMediaQuery("(max-width: 768px)");
|
||||||
if (!data || data.length === 0) {
|
if (!data || data.length === 0) {
|
||||||
return <div className="text-center">Nessuna comunicazione trovata</div>;
|
return <div className="text-center">Nessuna comunicazione trovata</div>;
|
||||||
}
|
}
|
||||||
|
const iframeHeight = mobile ? 600 : 500;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto py-4">
|
<div className="mx-auto py-4">
|
||||||
<Accordion
|
<Accordion
|
||||||
className="space-y-4"
|
className="space-y-4"
|
||||||
collapsible
|
collapsible
|
||||||
onValueChange={setOpenIndex}
|
onValueChange={setOpenEmail}
|
||||||
type="single"
|
type="single"
|
||||||
value={openIndex}
|
value={openEmail}
|
||||||
>
|
>
|
||||||
{data.map((email, index) => (
|
{data.map((email) => {
|
||||||
<AccordionItem
|
const isOpen = openEmail === email.id_email.toString();
|
||||||
className="space-y-2 border-none"
|
return (
|
||||||
key={email.title}
|
<AccordionItem
|
||||||
value={String(index)}
|
className="space-y-2 border-none"
|
||||||
>
|
key={email.id_email}
|
||||||
<AccordionTrigger className="rounded-md bg-muted px-4 py-2">
|
value={email.id_email.toString()}
|
||||||
Email “{email.title}” del{" "}
|
>
|
||||||
{new Date(email.created_at).toLocaleDateString("it-IT", {
|
<AccordionTrigger className="rounded-md bg-muted px-4 py-2">
|
||||||
day: "2-digit",
|
“{email.title}” -{" "}
|
||||||
month: "2-digit",
|
{new Date(email.created_at).toLocaleDateString("it-IT", {
|
||||||
year: "numeric",
|
day: "2-digit",
|
||||||
})}
|
month: "2-digit",
|
||||||
</AccordionTrigger>
|
year: "numeric",
|
||||||
<AccordionContent>
|
hour: "2-digit",
|
||||||
{openIndex === String(index) && (
|
minute: "2-digit",
|
||||||
<iframe
|
})}
|
||||||
height={mobile ? "600px" : "500px"}
|
</AccordionTrigger>
|
||||||
srcDoc={email.html}
|
<AccordionContent className="overflow-clip rounded-md border">
|
||||||
title="Email Content"
|
{isAdmin && (
|
||||||
width="100%"
|
<div className="flex h-7 w-full items-center justify-end bg-secondary px-4">
|
||||||
/>
|
<Button
|
||||||
)}
|
className="h-5"
|
||||||
</AccordionContent>
|
onClick={() =>
|
||||||
</AccordionItem>
|
confirm(
|
||||||
))}
|
"Sei sicuro di voler eliminare questa email?",
|
||||||
|
) &&
|
||||||
|
deleteEmail({
|
||||||
|
id_email: email.id_email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
variant="destructive"
|
||||||
|
>
|
||||||
|
Rimuovi
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<LoadingEmailIframe
|
||||||
|
height={iframeHeight}
|
||||||
|
srcDoc={email.html}
|
||||||
|
title="Email Content"
|
||||||
|
width="100%"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LoadingEmailIframe = (props: IframeHTMLAttributes<HTMLIFrameElement>) => {
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden"
|
||||||
|
style={{ height: props.height, width: props.width }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center bg-muted transition-opacity duration-1000 ${loaded ? "pointer-events-none opacity-0" : "opacity-100"}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="size-8 animate-spin rounded-full border-4 border-muted-foreground/20 border-t-primary" />
|
||||||
|
<p className="animate-pulse text-muted-foreground text-sm">
|
||||||
|
Caricamento email...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: <fine here> */}
|
||||||
|
<iframe
|
||||||
|
{...props}
|
||||||
|
className={`absolute inset-0 transition-opacity duration-1000 ${loaded ? "opacity-100" : "opacity-0"}
|
||||||
|
`}
|
||||||
|
onLoad={() => setLoaded(true)}
|
||||||
|
title={props.title ?? "Email preview"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { GetServerSideProps } from "next";
|
||||||
import { EmailAccordion } from "~/components/area-riservata/comunicazioni";
|
import { EmailAccordion } from "~/components/area-riservata/comunicazioni";
|
||||||
import { AreaRiservataLayoutUserView } from "~/components/Layout";
|
import { AreaRiservataLayoutUserView } from "~/components/Layout";
|
||||||
import type { NextPageWithLayout } from "~/pages/_app";
|
import type { NextPageWithLayout } from "~/pages/_app";
|
||||||
|
import { useEnforcedSession } from "~/providers/SessionProvider";
|
||||||
import type { UsersId } from "~/schemas/public/Users";
|
import type { UsersId } from "~/schemas/public/Users";
|
||||||
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
|
import { TrpcAuthedFetchingIstance } from "~/server/utils/ssgHelper";
|
||||||
import { zUserId } from "~/server/utils/zod_types";
|
import { zUserId } from "~/server/utils/zod_types";
|
||||||
|
|
@ -13,6 +14,7 @@ type ComunicazioniUserProps = {
|
||||||
const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
|
const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
|
||||||
userId,
|
userId,
|
||||||
}: ComunicazioniUserProps) => {
|
}: ComunicazioniUserProps) => {
|
||||||
|
const { user } = useEnforcedSession();
|
||||||
return (
|
return (
|
||||||
<main className="flex flex-1 flex-col items-start gap-4 md:gap-8">
|
<main className="flex flex-1 flex-col items-start gap-4 md:gap-8">
|
||||||
<div className="grid w-full auto-rows-max items-start gap-2 md:gap-4">
|
<div className="grid w-full auto-rows-max items-start gap-2 md:gap-4">
|
||||||
|
|
@ -20,7 +22,7 @@ const ComunicazioniUser: NextPageWithLayout<ComunicazioniUserProps> = ({
|
||||||
<h3 className="font-bold text-2xl">Comunicazioni</h3>
|
<h3 className="font-bold text-2xl">Comunicazioni</h3>
|
||||||
|
|
||||||
<div className="w-full py-4">
|
<div className="w-full py-4">
|
||||||
<EmailAccordion userId={userId} />
|
<EmailAccordion isAdmin={user.isAdmin} userId={userId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,10 @@ const Comunicazioni: NextPageWithLayout = () => {
|
||||||
<h1 className="font-bold text-2xl text-primary">Comunicazioni</h1>
|
<h1 className="font-bold text-2xl text-primary">Comunicazioni</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<EmailAccordion userId={session.user.id} />
|
<EmailAccordion
|
||||||
|
isAdmin={session.user.isAdmin}
|
||||||
|
userId={session.user.id}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
import type { EmailsIdEmail } from "~/schemas/public/Emails";
|
||||||
import {
|
import {
|
||||||
adminProcedure,
|
adminProcedure,
|
||||||
createTRPCRouter,
|
createTRPCRouter,
|
||||||
protectedProcedure,
|
protectedProcedure,
|
||||||
} from "~/server/api/trpc";
|
} from "~/server/api/trpc";
|
||||||
import { GetEmails } from "~/server/services/email.service";
|
import { DeleteEmail, GetEmails } from "~/server/services/email.service";
|
||||||
import { NewMail } from "~/server/services/mailer";
|
import { NewMail } from "~/server/services/mailer";
|
||||||
import { zUserId } from "~/server/utils/zod_types";
|
import { zUserId } from "~/server/utils/zod_types";
|
||||||
|
|
||||||
|
|
@ -27,6 +28,17 @@ export const comunicazioniRouter = createTRPCRouter({
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
deleteEmail: adminProcedure
|
||||||
|
.input(
|
||||||
|
z.object({
|
||||||
|
id_email: z.custom<EmailsIdEmail>(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input }) => {
|
||||||
|
return await DeleteEmail({
|
||||||
|
id_email: input.id_email,
|
||||||
|
});
|
||||||
|
}),
|
||||||
// storeEmail: publicProcedure
|
// storeEmail: publicProcedure
|
||||||
// .input(
|
// .input(
|
||||||
// z.object({
|
// z.object({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { TRPCError } from "@trpc/server";
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import type { EmailsIdEmail } from "~/schemas/public/Emails";
|
||||||
import type { UsersId } from "~/schemas/public/Users";
|
import type { UsersId } from "~/schemas/public/Users";
|
||||||
import { db } from "~/server/db";
|
import { db } from "~/server/db";
|
||||||
import { genMail, type MailsTemplates } from "~/server/services/mailer";
|
import { genMail, type MailsTemplates } from "~/server/services/mailer";
|
||||||
|
|
@ -41,6 +42,7 @@ export const GetEmails = async ({ userId }: { userId: UsersId }) => {
|
||||||
return {
|
return {
|
||||||
...(await genMail(mail.data as MailsTemplates)),
|
...(await genMail(mail.data as MailsTemplates)),
|
||||||
created_at: mail.created_at,
|
created_at: mail.created_at,
|
||||||
|
id_email: mail.id_email,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
@ -53,3 +55,18 @@ export const GetEmails = async ({ userId }: { userId: UsersId }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const DeleteEmail = async ({
|
||||||
|
id_email,
|
||||||
|
}: {
|
||||||
|
id_email: EmailsIdEmail;
|
||||||
|
}) => {
|
||||||
|
try {
|
||||||
|
await db.deleteFrom("emails").where("id_email", "=", id_email).execute();
|
||||||
|
} catch (e) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
message: `Errore durante l'eliminazione dell'email: ${(e as Error).message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue