infoalloggi-monorepo/apps/infoalloggi/src/components/servizio/servizio.tsx

712 lines
20 KiB
TypeScript
Raw Normal View History

import { add, isAfter, isBefore } from "date-fns";
import {
ArrowRight,
BadgeCheck,
CalendarClock,
CircleCheck,
CircleCheckBig,
CircleMinus,
Clock,
ClockFading,
ExternalLink,
type LucideIcon,
MousePointerClick,
PackageCheck,
SearchX,
Trash2,
} from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { DisclaimerInterruzioneServizio } from "~/components/servizio/interruzione";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import {
ServizioAnnuncioProvider,
ServizioProvider,
useServizio,
} from "~/providers/ServizioProvider";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { Rinnovi } from "~/schemas/public/Rinnovi";
import type { UsersId } from "~/schemas/public/Users";
import type { ServizioData } from "~/server/controllers/servizio.controller";
import { api } from "~/utils/api";
import { DEFAULT_SERVIZIO_DURATION_DAYS } from "~/utils/config";
import { Confirm } from "../confirm";
import { AlarmClockSVG } from "../svgs";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "../ui/alert-dialog";
import { Button } from "../ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../ui/card";
import { SaldoButton } from "./annuncio_actions";
import { AnnuncioCard, AnnuncioDisplay } from "./annuncio_card";
import { AnnunciCompatibili } from "./compatibili_dialog";
import { AdminServizioActions, ServizioInfos } from "./servizio_actions";
export const ServizioList = ({
data,
isAdmin,
userId,
}: {
data: (
| { d: Date; type: "servizio"; data: ServizioData }
| { d: Date; type: "rinnovo"; data: Rinnovi }
)[];
isAdmin: boolean;
userId: UsersId;
}) => {
const { t } = useTranslation();
if (data.length === 0) {
return (
<div className="m-4 rounded-md bg-yellow-100 p-4 text-yellow-900">
{t.servizio.nessun_servizio}
</div>
);
}
return (
<div className="flex flex-col gap-4">
{data.map(({ d: _d, type, data: s }) => {
if (type === "rinnovo") {
return (
<RinnovoLinkCard
data={s}
isAdmin={isAdmin}
key={s.id}
userId={userId}
/>
);
}
return (
<ServizioLinkCard
isAdmin={isAdmin}
key={s.servizio_id}
servizio={s}
userId={userId}
/>
);
})}
</div>
);
};
const RinnovoLinkCard = ({
data,
isAdmin,
userId,
}: {
data: Rinnovi;
isAdmin: boolean;
userId: UsersId;
}) => {
return (
<Link
className="group"
href={
isAdmin
? `/area-riservata/admin/user-view/rinnovo/${userId}/${data.id}`
: `/area-riservata/rinnovo/${data.id}`
}
key={data.id}
>
<Card
className={cn("transition-shadow hover:bg-muted/15 hover:shadow-md")}
>
<CardHeader>
<CardTitle className="text-xl group-hover:underline">
Rinnovo {data.codice}
</CardTitle>
<CardDescription className="">
Data rinnovo: {data.decorrenza.toLocaleDateString("it-IT")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col flex-wrap gap-4 sm:flex-row sm:justify-between">
<div className="flex items-center gap-2 rounded-md text-muted-foreground group-hover:text-foreground">
<span>Vai al rinnovo</span>
<ArrowRight />
</div>
</CardContent>
</Card>
</Link>
);
};
const ServizioLinkCard = ({
servizio,
isAdmin,
userId,
}: {
servizio: ServizioData;
isAdmin: boolean;
userId: UsersId;
}) => {
const { t } = useTranslation();
let status: keyof typeof t.servizio.status = "attivo";
let StatusIcon: LucideIcon = CircleCheck;
if (servizio.isInterrotto) {
status = "interrotto";
StatusIcon = SearchX;
} else if (!servizio.isOkAcconto) {
status = "in_attesa";
StatusIcon = ClockFading;
} else if (servizio.decorrenza) {
const now = new Date();
const decorrenza = new Date(servizio.decorrenza);
if (
now > add(decorrenza, { days: DEFAULT_SERVIZIO_DURATION_DAYS }) &&
!servizio.isOkSaldo
) {
status = "scaduto";
StatusIcon = CircleMinus;
}
if (servizio.isOkSaldo) {
status = "completato";
StatusIcon = CircleCheckBig;
}
}
return (
<Link
className="group"
href={
isAdmin
? `/area-riservata/admin/user-view/servizio/${userId}/${servizio.servizio_id}`
: `/area-riservata/servizio/${servizio.servizio_id}`
}
key={servizio.servizio_id}
>
{status === "in_attesa" ? (
<Card className="relative border-yellow-500 bg-yellow-500 text-white hover:bg-yellow-500/90 dark:bg-yellow-900 dark:hover:bg-yellow-900/90">
<span className="absolute top-5 right-5 -mt-1 -mr-1 flex size-7 items-center justify-center">
<span className="absolute inline-flex size-6 h-full w-full animate-ping rounded-full bg-white opacity-75" />
<span className="relative inline-flex size-5 rounded-full bg-white"></span>
</span>
<CardHeader>
<CardTitle className="text-xl group-hover:underline">
{t.servizio.servizio_titolo} {servizio.tipologia}
</CardTitle>
<CardDescription className="text-white">
{t.servizio.servizio_creato_il}{" "}
{servizio.created_at.toLocaleDateString("it-IT")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col flex-wrap gap-4 sm:flex-row sm:justify-between">
<div className="flex items-center gap-2">
<StatusIcon className="size-5" />
<span>{t.servizio.status[status]}</span>
</div>
<div className="flex items-center gap-2 rounded-md font-semibold text-white underline underline-offset-2">
<span>{t.servizio.vai_al_servizio}</span>
<MousePointerClick />
</div>
</CardContent>
</Card>
) : (
<Card
className={cn(
status === "attivo" && "border-green-500",
status === "interrotto" && "border-red-500",
status === "scaduto" && "border-gray-500",
status === "completato" && "border-blue-500",
"transition-shadow hover:bg-muted/15 hover:shadow-md",
)}
>
<CardHeader>
<CardTitle className="text-xl group-hover:underline">
{t.servizio.servizio_titolo} {servizio.tipologia}
</CardTitle>
<CardDescription className="">
{t.servizio.servizio_creato_il}{" "}
{servizio.created_at.toLocaleDateString("it-IT")}
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col flex-wrap gap-4 sm:flex-row sm:justify-between">
<div
className={cn(
"flex items-center gap-2",
status === "attivo" && "text-green-500",
status === "interrotto" && "text-red-500",
status === "scaduto" && "text-gray-500",
status === "completato" && "text-blue-500",
)}
>
<StatusIcon className="size-5" />
<span>{t.servizio.status[status]}</span>
</div>
<div className="flex items-center gap-2 rounded-md font-semibold text-muted-foreground group-hover:text-foreground">
<span>{t.servizio.vai_al_servizio}</span>
<MousePointerClick />
</div>
</CardContent>
</Card>
)}
</Link>
);
};
export const Servizio = ({
servizio,
userId,
isAdmin,
}: {
servizio: ServizioData;
userId: UsersId;
isAdmin: boolean;
}) => {
return (
<ServizioProvider
isAdmin={isAdmin}
servizio={servizio}
servizioId={servizio.servizio_id}
userId={userId}
>
<Content />
</ServizioProvider>
);
};
const Content = () => {
const { servizio } = useServizio();
const { t } = useTranslation();
const dec_txt = servizio.decorrenza
? new Date(servizio.decorrenza).toLocaleDateString("it-IT")
: "";
const end_txt = servizio.decorrenza
? new Date(
add(servizio.decorrenza, { days: DEFAULT_SERVIZIO_DURATION_DAYS }),
).toLocaleDateString("it-IT")
: "";
return (
<div className="w-full space-y-4">
<div className="flex flex-col flex-wrap items-start justify-between gap-4 sm:flex-row">
<div className="flex flex-col gap-1">
<h3 className="font-semibold text-2xl">
{t.servizio.servizio_titolo} {servizio.tipologia}
</h3>
<div className="flex items-center gap-2 text-muted-foreground text-sm">
<Clock className="size-5" />
<span>
Creato il: {servizio.created_at.toLocaleDateString("it-IT")}
</span>
</div>
<div className="flex flex-wrap items-center gap-2 text-muted-foreground text-sm">
<div className="flex items-center gap-2">
<CalendarClock className="size-5" />
<span>{t.servizio.durata_servizio}</span>
</div>
<span>
{servizio.decorrenza !== null
? `${dec_txt} - ${end_txt}`
: "In attesa di attivazione"}
</span>
</div>
</div>
<AdminServizioActions />
</div>
<ServizioInfos />
<Main />
</div>
);
};
const Main = () => {
const { servizio, isAdmin, userId } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate: attivazioneSenzaPagamento } =
api.servizio.attivazioneSenzaPagamento.useMutation({
onError: () => {
toast.error("Errore durante l'attivazione del servizio");
},
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.invalidate();
},
});
// se interrotto, mostra avvisos
if (servizio.isInterrotto) {
if (isAdmin) {
return (
<>
<div className="flex w-full items-center justify-center rounded-md bg-red-100 p-2 text-red-900">
<div className="flex items-center gap-2">
<SearchX className="size-5 text-red-500" />
<span>{t.servizio.servizio_interrotto}</span>
</div>
</div>
<div className="@container grid grid-flow-row @sm:grid-cols-2 grid-cols-1 gap-4">
{servizio.annunci.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
>
<AnnuncioCard />
</ServizioAnnuncioProvider>
);
})}
{servizio.annunci.length > 1 &&
servizio.annunci.length % 2 === 1 && (
<div className="@sm:block hidden size-full rounded-md border border-(--pattern-fg) bg-[repeating-linear-gradient(45deg,var(--pattern-fg)_0,var(--pattern-fg)_1px,transparent_0,transparent_50%)] bg-size-[10px_10px] bg-fixed opacity-10 [--pattern-fg:#696969] dark:[--pattern-fg:#e1e1e1]" />
)}
</div>
</>
);
}
return (
<div className="flex w-full flex-col items-center justify-center gap-4 py-10 text-center">
<div className="flex size-14 items-center justify-center rounded-full bg-red-100 dark:bg-red-500/10">
<SearchX className="size-7 text-red-500 dark:text-red-400" />
</div>
<div className="flex flex-col gap-1">
<p className="font-semibold text-xl">
{t.servizio.servizio_interrotto}
</p>
<p className="max-w-sm text-muted-foreground text-sm">
{t.servizio.servizio_interrotto_desc}
</p>
</div>
<Link
href={
isAdmin
? `/area-riservata/admin/user-view/ricerca/${userId}`
: "/area-riservata/dashboard"
}
>
<Button variant="secondary">Torna alla lista dei servizi</Button>
</Link>
</div>
);
}
// mostra onboarding
if (!servizio.onboardOk) {
return (
<div className="flex w-full flex-col items-center justify-center gap-8">
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<AlarmClockSVG className="size-46" />
<h1 className="text-xl">{t.servizio.non_attivo}</h1>
<Link
aria-label="Attiva Servizio"
className="w-full"
href={
isAdmin
? `/area-riservata/admin/user-view/onboard/${servizio.servizio_id}`
: `/servizio/onboard/${servizio.servizio_id}`
}
>
<Button className="w-full text-lg" variant="success">
<span>{t.servizio.attiva_cta}</span>
<ArrowRight />
</Button>
</Link>
</div>
<AnnunciAnteprima />
{isAdmin && <AnnunciCompatibili />}
</div>
);
}
// se non ok acconto, mostra invito a completare pagamento acconto o ad attivare se acconto già inserito
if (!servizio.isOkAcconto || servizio.decorrenza === null) {
const acconti = servizio.ordini.filter(
(o) => o.type === OrderTypeEnum.Acconto,
);
const activeAcconto = acconti.find((a) => a.isActive);
return (
<div className="flex w-full flex-col items-center justify-center gap-8">
<div className="flex w-full max-w-md flex-col items-center justify-center gap-4 text-center">
<PackageCheck className="size-46 stroke-1" />
<h1 className="text-xl">Parametri di ricerca salvati</h1>
{activeAcconto ? (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
// se acconto inserito, attivato ma servizio non ancora attivo
className="w-full text-lg"
variant="info"
>
<span>Attiva il servizio</span>
<ArrowRight />
</Button>
</AlertDialogTrigger>
<AlertDialogContent className="sm:max-w-xl">
<AlertDialogHeader>
<div className="mb-4 flex size-9 items-center justify-center rounded-full bg-sky-600/10 sm:mx-0 dark:bg-sky-400/10">
<BadgeCheck className="size-4.5 text-sky-600 dark:text-sky-400" />
</div>
<AlertDialogTitle className="text-xl">
Attivazione del Servizio
</AlertDialogTitle>
<AlertDialogDescription>
Procedendo all'attivazione del servizio, confermi che i
parametri di ricerca salvati sono corretti e accetti di
essere contattato per eventuali verifiche.
</AlertDialogDescription>
{activeAcconto?.testo_condizioni && (
<div className="mt-4 w-full space-y-4">
<Link
aria-label="Leggi le condizioni"
className="block w-fit"
href={`/servizio/condizioni/${activeAcconto.testo_condizioni}`}
target="_blank"
>
<Button className="w-full text-sm">
<span>{t.acquisto.leggi_condizioni}</span>
<ExternalLink />
</Button>
</Link>
<p className="text-muted-foreground text-sm">
Riceverai copia delle condizioni anche via email
</p>
</div>
)}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
className="bg-sky-600 text-white hover:bg-sky-600 focus-visible:ring-sky-600 dark:bg-sky-400 dark:focus-visible:ring-sky-400 dark:hover:bg-sky-400"
onClick={() => {
attivazioneSenzaPagamento({
ordineId: activeAcconto.ordine_id,
});
}}
>
<span>Attiva il servizio</span>
<ArrowRight className="size-4" />
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
) : (
<Link
aria-label="Attiva Servizio"
className="w-full"
href={`/servizio/pagamento?servizioId=${servizio.servizio_id}&type=${OrderTypeEnum.Acconto}`}
>
<Button className="w-full text-lg" variant="info">
<span>Procedi al pagamento</span>
<ArrowRight />
</Button>
</Link>
)}
</div>
<AnnunciAnteprima />
{isAdmin && <AnnunciCompatibili />}
</div>
);
}
//servizio scaduto se decorrenza + 60 giorni è passato e non è ok saldo
if (
new Date() >
add(servizio.decorrenza, {
days: DEFAULT_SERVIZIO_DURATION_DAYS,
}) &&
!servizio.isOkSaldo
) {
//todo capire come fare con saldo quando scade
return (
<>
<p className="font-medium text-lg">{t.servizio.scaduto}</p>
<p>{t.servizio.scaduto_desc}</p>
</>
);
}
const annuncioConfermato = servizio.annunci.filter(
(a) => a.accettato_conferma_at !== null,
);
// in conferma, mostra solo annunci confermati
if (servizio.isOkSaldo && annuncioConfermato.length > 0) {
return (
<>
{annuncioConfermato.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
>
<AnnuncioCard className="max-w-full" />
</ServizioAnnuncioProvider>
);
})}
</>
);
}
// se superato periodo interruzione, ma non interrotto, non scaduto e non in conferma, mostra avviso
if (
!isAdmin &&
!servizio.acceptedInterr &&
isBefore(
new Date(),
add(servizio.decorrenza, { days: servizio.interruzioneDays }),
) &&
isAfter(
new Date(),
add(servizio.decorrenza, { days: servizio.interruzioneDays - 5 }),
)
) {
return <DisclaimerInterruzioneServizio />;
}
// normal case
return (
<>
{annuncioConfermato.length > 0 && (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2">
<PackageCheck className="size-6 text-primary" />
<span className="font-medium">{t.servizio.saldi_in_attesa}</span>
</div>
{annuncioConfermato.map((a) => (
<div
className="flex items-center gap-2 rounded-md bg-sky-400 p-2"
key={a.annunci_id}
>
<h3>
{a.codice} -{" "}
{a.accettato_conferma_at &&
new Date(a.accettato_conferma_at).toLocaleDateString("it", {
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
month: "2-digit",
year: "numeric",
})}
</h3>
<SaldoButton
annuncioId={a.annunci_id}
className="border-none"
variant={"outline"}
/>
</div>
))}
</div>
)}
<div className="@container grid grid-flow-row @sm:grid-cols-2 grid-cols-1 gap-4">
{servizio.annunci.map((data) => {
return (
<ServizioAnnuncioProvider
data={data}
key={data.id}
servizioId={servizio.servizio_id}
>
<AnnuncioCard />
</ServizioAnnuncioProvider>
);
})}
{servizio.annunci.length > 1 && servizio.annunci.length % 2 === 1 && (
<div className="@sm:block hidden size-full rounded-md border border-(--pattern-fg) bg-[repeating-linear-gradient(45deg,var(--pattern-fg)_0,var(--pattern-fg)_1px,transparent_0,transparent_50%)] bg-size-[10px_10px] bg-fixed opacity-10 [--pattern-fg:#696969] dark:[--pattern-fg:#e1e1e1]" />
)}
</div>
<div className="flex flex-col items-center justify-center gap-4 py-4">
{servizio.annunci.length === 0 && (
<h2 className="font-medium text-lg">Cerca un annuncio ora!</h2>
)}
<AnnunciCompatibili />
</div>
</>
);
};
const AnnunciAnteprima = () => {
const { servizio, isAdmin } = useServizio();
const { t } = useTranslation();
const utils = api.useUtils();
const { mutate: removeAnnuncioServizio } =
api.servizio.removeAnnuncioServizio.useMutation({
onError: () => {
toast.error("Errore durante la rimozione dell'annuncio dal servizio");
},
onSuccess: async () => {
await utils.servizio.invalidate();
toast.success("Annuncio rimosso");
},
});
if (servizio.annunci.length > 0) {
return (
<div className="w-full space-y-2">
<p>
{t.servizio.per_te_1} {servizio.annunci.length} {t.servizio.per_te_2}
</p>
<div className="grid gap-4 sm:grid-cols-2">
{servizio.annunci.map((data) => (
<div className="group relative" key={data.id}>
<AnnuncioDisplay
className="bg-[#e6e9ec]/50 dark:bg-card dark:outline dark:outline-foreground/10"
data={{
...data,
modificato_il: data.modificato_il
? new Date(data.modificato_il)
: null,
media_updated_at: data.media_updated_at
? new Date(data.media_updated_at)
: null,
}}
/>
{isAdmin && (
<Confirm
description="Sei sicuro di voler rimuovere questo annuncio?"
onConfirm={() => {
removeAnnuncioServizio({
annuncioId: data.annunci_id,
servizioId: servizio.servizio_id,
});
}}
title="Rimuovi Annuncio"
>
<Button
aria-label="Rimuovi"
className="absolute top-0 right-0 hidden group-hover:flex"
size="sm"
variant="destructive"
>
<Trash2 />
</Button>
</Confirm>
)}
</div>
))}
{servizio.annunci.length % 2 === 1 && (
<div className="hidden size-full rounded-md border border-(--pattern-fg) bg-[repeating-linear-gradient(45deg,var(--pattern-fg)_0,var(--pattern-fg)_1px,transparent_0,transparent_50%)] bg-size-[10px_10px] bg-fixed opacity-10 [--pattern-fg:#696969] sm:block dark:[--pattern-fg:#e1e1e1]" />
)}
</div>
</div>
);
}
return null;
};