Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Metadata } from "next";
import { AuthCheck } from "@/components/auth-check";
import Navbar from "@/components/navbar";
import { InstallPromptProvider } from "@/contexts/install-prompt-provider";
import FeedbackBanner from "@/components/feedback/banner";

export const metadata: Metadata = {
title: "Calendar | Pombo",
Expand Down Expand Up @@ -48,6 +49,7 @@ export default function AppLayout({
<AuthCheck shouldBeLoggedIn>
<InstallPromptProvider>
<div className="flex h-dvh flex-col">
<FeedbackBanner bannerText="Having issues?" linkText="Click here" />
<Navbar />
<main className="min-h-0 flex-1 px-5 pt-3.5 pb-7.5 antialiased md:px-7.5">
{children}
Expand Down
181 changes: 181 additions & 0 deletions src/app/(app)/settings/backoffice/feedbacks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"use client";

import { useGetFeedbacks } from "@/lib/queries/feedback";
import { useDeleteFeedback } from "@/lib/mutations/feedback";
import { useState, Fragment } from "react";
import {
Transition,
TransitionChild,
Dialog,
DialogPanel,
} from "@headlessui/react";

function ConfirmDelete({
state,
setState,
onConfirm,
errorMessage,
}: {
state: boolean;
setState: (state: boolean) => void;
onConfirm: () => void;
errorMessage: string | null;
}) {
return (
<Transition appear show={state} as={Fragment}>
<Dialog
as="div"
className="relative z-50"
onClose={() => setState(false)}
>
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-200"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 backdrop-blur-sm" />
</TransitionChild>

<div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<DialogPanel className="relative w-full max-w-md flex-1 space-y-4 rounded-lg bg-white p-6 shadow-lg focus:outline-0">
<h2 className="px-2 py-1 text-lg font-semibold">
Are you sure you want to delete this feedback report?
</h2>
{errorMessage && (
<p className="text-danger mt-2 text-center text-sm">
{errorMessage}
</p>
)}
<div className="flex flex-row justify-end gap-4">
<button
onClick={() => setState(false)}
className="cursor-pointer rounded-lg bg-gray-200 px-4 py-2 transition-all duration-150 select-none hover:bg-gray-200/80"
>
Cancel
</button>
<button
onClick={onConfirm}
className="bg-danger hover:bg-danger/80 cursor-pointer rounded-lg px-4 py-2 text-white/90 transition-all duration-150 select-none"
>
Yes, delete it
</button>
</div>
</DialogPanel>
</TransitionChild>
</div>
</Dialog>
</Transition>
);
}

interface IFeedback {
id: string;
subject: string;
message: string;
user_id: string;
inserted_at: string;
updated_at: string;
}

export default function Feedbacks() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const [isConfirmationVisible, setConfirmationVisible] = useState(false);
const [selectedFeedbackId, setSelectedFeedbackId] = useState<string | null>(
null,
);

const { data: feedbacksRaw } = useGetFeedbacks();
const feedbacks: IFeedback[] = feedbacksRaw?.data ?? [];

const deleteMutation = useDeleteFeedback();

const handleDelete = (id: string) => {
setErrorMessage(null);

deleteMutation.mutate(id, {
onSuccess: () => {
setConfirmationVisible(false);
setSelectedFeedbackId(null);
},
onError: () => setErrorMessage("An error occurred."),
});
};

return (
<div className="flex h-full flex-col gap-8 pb-8">
<section className="space-y-2">
<h2 className="text-2xl font-semibold">Feedbacks</h2>
<p>View submitted feedback reports</p>
</section>
<section>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">
Subject
</th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">
Message
</th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">
Submitted
</th>
<th className="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>

<tbody className="divide-y divide-gray-200 bg-white">
{feedbacks.map((fb: IFeedback) => (
<tr key={fb.id}>
<td className="max-w-xs px-6 py-4 text-sm break-words whitespace-normal text-gray-900">
{fb.subject}
</td>
<td className="max-w-xs px-6 py-4 text-sm break-words whitespace-normal text-gray-900">
{fb.message}
</td>
<td className="px-6 py-4 text-sm whitespace-nowrap text-gray-900">
{new Date(fb.inserted_at).toLocaleString()}
</td>
<td className="px-6 py-4 text-left text-sm font-medium">
<button
onClick={() => {
setSelectedFeedbackId(fb.id);
setConfirmationVisible(true);
}}
className="text-danger cursor-pointer"
>
Delete
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<ConfirmDelete
state={isConfirmationVisible}
setState={setConfirmationVisible}
onConfirm={() => selectedFeedbackId && handleDelete(selectedFeedbackId)}
errorMessage={errorMessage}
/>
</div>
);
}
83 changes: 83 additions & 0 deletions src/components/feedback/add-feedback-content.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { useState } from "react";
import clsx from "clsx";
import { twMerge } from "tailwind-merge";
import { useCreateFeedback } from "@/lib/mutations/feedback";

interface IAddFeedbackContent {
setModalState: (state: boolean) => void;
}

function AddFeedbackContent({ setModalState }: IAddFeedbackContent) {
const [errorMessage, setErrorMessage] = useState<string | null>(null);

const [subject, setSubject] = useState("");
const [message, setMessage] = useState("");

const createFeedback = useCreateFeedback();

function handleSubmit() {
setErrorMessage(null);

createFeedback.mutate(
{
subject,
message,
},
{
onSuccess: () => setModalState(false),
onError: () => setErrorMessage("An error occurred."),
},
);
}

const isFormFilled = !!subject.trim() && !!message.trim();

return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<h2 className="px-2 py-1 text-sm font-semibold">Subject</h2>
<input
id="subject"
type="text"
placeholder="Subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
className={
"group flex w-full items-center justify-center rounded-md border border-gray-300 p-2 text-left select-none focus:outline-none"
}
></input>
<h2 className="px-2 py-1 text-sm font-semibold">Describe the issue</h2>
<textarea
id="message"
placeholder="Description"
value={message}
onChange={(e) => setMessage(e.target.value)}
className={
"group flex min-h-40 w-full resize-none items-center justify-center rounded-md border border-gray-300 p-2 text-left select-none focus:outline-none"
}
></textarea>
</div>

{errorMessage && (
<p className="text-danger mt-2 text-center text-sm">{errorMessage}</p>
)}

<button
onClick={handleSubmit}
disabled={!isFormFilled}
className={twMerge(
clsx(
"bg-celeste mt-4 cursor-pointer rounded-lg px-4 py-2 text-white/90 transition-all duration-150 select-none",
!isFormFilled
? "cursor-not-allowed opacity-50"
: "hover:bg-celeste/80",
),
)}
>
Submit
</button>
</div>
);
}

export default AddFeedbackContent;
74 changes: 74 additions & 0 deletions src/components/feedback/banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
"use client";

import { useState } from "react";
import { twMerge } from "tailwind-merge";
import FeedbackModal from "./modal";
import AddFeedbackContent from "./add-feedback-content";
import { useGetUserInfo } from "@/lib/queries/session";

interface IFeedbackBanner {
bannerText: string;
linkText: string;
}

function FeedbackBanner({ bannerText, linkText }: IFeedbackBanner) {
const [isVisible, setIsVisible] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const [isModalVisible, setModalVisible] = useState(false);

const { data: user } = useGetUserInfo();
const type = user?.type;
if (type === "admin") return null;

function handleCloseBanner() {
setIsClosing(true);
setTimeout(() => {
setIsVisible(false);
}, 500);
}

if (!isVisible) return null;

return (
<div className="fixed bottom-0 left-1/2 z-50 w-full -translate-x-1/2 transform md:bottom-4 md:w-auto">
<div
className={twMerge(
"bg-primary-400 text-light flex transform px-4 py-3 transition-all duration-500 md:rounded-xl",
isClosing
? "translate-y-full opacity-0"
: "translate-y-0 opacity-100",
)}
>
<div>
<span className="material-symbols-outlined align-bottom text-xl font-bold">
mode_comment
</span>
<span>
{" "}
<strong>Feedback ·</strong> {bannerText}
</span>
<button
onClick={() => setModalVisible(true)}
className="cursor-pointer pl-1 font-bold underline"
>
{linkText}
</button>
</div>
<button onClick={handleCloseBanner} className="ml-auto shrink-0">
<span className="material-symbols-outlined cursor-pointer pl-6 align-bottom text-xl font-bold">
cancel
</span>
</button>
</div>
<FeedbackModal
modalState={isModalVisible}
setModalState={setModalVisible}
title="Give feedback"
>
<AddFeedbackContent setModalState={setModalVisible} />
</FeedbackModal>
</div>
);
}

export default FeedbackBanner;
Loading