eslint migration biome

This commit is contained in:
Marco Pedone 2025-08-28 18:27:07 +02:00
parent 9185d8ddec
commit 3f82763168
348 changed files with 64918 additions and 67612 deletions

View file

@ -17,6 +17,4 @@ knip.json
check-env-vars.js check-env-vars.js
headers headers
.kanelrc.js .kanelrc.js
eslint.config.mjs
prettier.config.cjs
kyselyRules kyselyRules

View file

@ -1,21 +1,16 @@
{ {
"typescript.tsdk": "node_modules\\typescript\\lib", "typescript.tsdk": "node_modules\\typescript\\lib",
"sqltools.connections": [ "sqltools.connections": [
{ {
"previewLimit": 50, "previewLimit": 50,
"server": "localhost", "server": "localhost",
"port": 5432, "port": 5432,
"driver": "PostgreSQL", "driver": "PostgreSQL",
"name": "DB", "name": "DB",
"database": "postgres", "database": "postgres",
"username": "postgres", "username": "postgres",
"password": "rootpost" "password": "rootpost"
} }
], ],
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
}
} }

View file

@ -44,7 +44,11 @@
"!*compose.yml", "!*compose.yml",
"!.kanelrc.js", "!.kanelrc.js",
"!TODO", "!TODO",
"!*.d.ts" "!*.d.ts",
"!src/i18n/comuni.ts",
"!src/i18n/nazioni.ts",
"!src/i18n/provincie.ts",
"!src/styles/globals.css"
], ],
"ignoreUnknown": true "ignoreUnknown": true
}, },
@ -54,6 +58,7 @@
}, },
"linter": { "linter": {
"enabled": true, "enabled": true,
"domains": { "domains": {
"next": "recommended", "next": "recommended",
"react": "recommended" "react": "recommended"
@ -61,14 +66,29 @@
"rules": { "rules": {
"recommended": true, "recommended": true,
"a11y": { "a11y": {
"noNoninteractiveElementInteractions": "error" "noNoninteractiveElementInteractions": "error",
"useSemanticElements": "off",
"noAutofocus": "off"
}, },
"suspicious": { "suspicious": {
"noArrayIndexKey": "off" "useIterableCallbackReturn": "off",
"noArrayIndexKey": "off",
"noUnknownAtRules": "off"
},
"complexity": {
"noUselessFragments": "off"
}, },
"correctness": { "correctness": {
"useExhaustiveDependencies": "off" "useHookAtTopLevel": "off",
"useExhaustiveDependencies": "off",
"useParseIntRadix": "off"
},
"performance": {
"noImgElement": "off"
},
"security": {
"noDangerouslySetInnerHtml": "off"
} }
} }
}, },

View file

@ -1,79 +1,79 @@
import { import {
Body, Body,
Container, Container,
Head, Head,
Html, Hr,
Preview, Html,
Section, Img,
Text, Preview,
Tailwind, Section,
Img, Tailwind,
Hr, Text,
} from "@react-email/components"; } from "@react-email/components";
import type { JSX } from "react"; import type { JSX } from "react";
const Base = ({ const Base = ({
children, children,
preview, preview,
noreply, noreply,
}: { }: {
children: JSX.Element; children: JSX.Element;
preview: string; preview: string;
noreply?: boolean; noreply?: boolean;
}) => { }) => {
return ( return (
<Tailwind <Tailwind
config={{ config={{
theme: { theme: {
container: { container: {
center: true, center: true,
padding: "2rem", padding: "2rem",
screens: { screens: {
"2xl": "1400px", "2xl": "1400px",
}, },
}, },
}, },
}} }}
> >
<Html> <Html>
<Head /> <Head />
<Preview>{preview}</Preview> <Preview>{preview}</Preview>
<Body className="py-2 text-center font-sans text-base text-neutral-700"> <Body className="py-2 text-center font-sans text-base text-neutral-700">
<Container className="max-w-xl rounded-lg border border-solid border-[#eaeaea] pt-2 pb-10"> <Container className="max-w-xl rounded-lg border border-solid border-[#eaeaea] pt-2 pb-10">
<Img <Img
referrerPolicy="no-referrer" referrerPolicy="no-referrer"
width={230.5} width={230.5}
height={44.25} height={44.25}
// src="https://lh3.google.com/u/0/d/1nEurjVWPiz0F8QRLhKcT4ZSujeFRChBs" // src="https://lh3.google.com/u/0/d/1nEurjVWPiz0F8QRLhKcT4ZSujeFRChBs"
src="cid:logo" src="cid:logo"
alt="Infoalloggi.it" alt="Infoalloggi.it"
className="mx-auto my-2 scale-80" className="mx-auto my-2 scale-80"
/> />
<Section className="max-w-lg px-8"> <Section className="max-w-lg px-8">
<Hr /> <Hr />
<Section className="max-w-md">{children}</Section> <Section className="max-w-md">{children}</Section>
<Section className="mt-4"> <Section className="mt-4">
{noreply && ( {noreply && (
<Text className="text-xs text-neutral-500"> <Text className="text-xs text-neutral-500">
Questo è un messaggio automatico, per favore non rispondere Questo è un messaggio automatico, per favore non rispondere
a questa email. a questa email.
</Text> </Text>
)} )}
</Section> </Section>
<Section className="mt-3"> <Section className="mt-3">
<Text className="text-left text-sm"> <Text className="text-left text-sm">
Arcenia S.r.l. Sede legale: Via Beata Giovanna 1, Bassano del Arcenia S.r.l. Sede legale: Via Beata Giovanna 1, Bassano del
Grappa (VI) Grappa (VI)
</Text> </Text>
</Section> </Section>
</Section> </Section>
</Container> </Container>
</Body> </Body>
</Html> </Html>
</Tailwind> </Tailwind>
); );
}; };
export default Base; export default Base;

View file

@ -2,43 +2,43 @@ import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type ContattoAdmin_NewMail = { export type ContattoAdmin_NewMail = {
mailType: "contattoAdminEmail"; mailType: "contattoAdminEmail";
props: ContattoAdminEmailProps; props: ContattoAdminEmailProps;
}; };
type ContattoAdminEmailProps = { type ContattoAdminEmailProps = {
nome: string; nome: string;
cognome: string; cognome: string;
email: string; email: string;
telefono: string; telefono: string;
messaggio: string; messaggio: string;
}; };
const ContattoAdminEmail = ({ const ContattoAdminEmail = ({
nome, nome,
cognome, cognome,
email, email,
telefono, telefono,
messaggio, messaggio,
}: ContattoAdminEmailProps) => { }: ContattoAdminEmailProps) => {
return ( return (
<Base preview="Contatto Email"> <Base preview="Contatto Email">
<> <>
<Heading className="text-center text-3xl leading-8"> <Heading className="text-center text-3xl leading-8">
Richiesta di contatto Richiesta di contatto
</Heading> </Heading>
<Section className="px-5 text-left text-base md:px-12"> <Section className="px-5 text-left text-base md:px-12">
<Row> <Row>
<Text>Data: {new Date().toLocaleString()}</Text> <Text>Data: {new Date().toLocaleString()}</Text>
<Text>Nome: {nome}</Text> <Text>Nome: {nome}</Text>
<Text>Cognome: {cognome}</Text> <Text>Cognome: {cognome}</Text>
<Text>Email: {email}</Text> <Text>Email: {email}</Text>
<Text>Telefono: {telefono}</Text> <Text>Telefono: {telefono}</Text>
<Text>Messaggio: {messaggio}</Text> <Text>Messaggio: {messaggio}</Text>
</Row> </Row>
</Section> </Section>
</> </>
</Base> </Base>
); );
}; };
export default ContattoAdminEmail; export default ContattoAdminEmail;

View file

@ -2,51 +2,51 @@ import { Button, Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type ContattoAnnuncio_NewMail = { export type ContattoAnnuncio_NewMail = {
mailType: "contattoAnnuncioEmail"; mailType: "contattoAnnuncioEmail";
props: ContattoAnnuncioEmailProps; props: ContattoAnnuncioEmailProps;
}; };
type ContattoAnnuncioEmailProps = { type ContattoAnnuncioEmailProps = {
nome: string; nome: string;
codice: string; codice: string;
email: string; email: string;
telefono: string; telefono: string;
messaggio: string; messaggio: string;
}; };
const ContattoAnnuncioEmail = ({ const ContattoAnnuncioEmail = ({
nome, nome,
codice, codice,
email, email,
telefono, telefono,
messaggio, messaggio,
}: ContattoAnnuncioEmailProps) => { }: ContattoAnnuncioEmailProps) => {
return ( return (
<Base preview="Contatto Email"> <Base preview="Contatto Email">
<> <>
<Heading className="text-center text-3xl leading-8"> <Heading className="text-center text-3xl leading-8">
Richiesta di contatto Richiesta di contatto
</Heading> </Heading>
<Section className="px-5 text-left text-base md:px-12"> <Section className="px-5 text-left text-base md:px-12">
<Row> <Row>
<Text>Data: {new Date().toLocaleString()}</Text> <Text>Data: {new Date().toLocaleString()}</Text>
<Text>Nome: {nome}</Text> <Text>Nome: {nome}</Text>
<Text>Annuncio: {codice}</Text> <Text>Annuncio: {codice}</Text>
<Text>Telefono: {telefono}</Text> <Text>Telefono: {telefono}</Text>
<Text>Email: {email}</Text> <Text>Email: {email}</Text>
<Text>Messaggio: {messaggio}</Text> <Text>Messaggio: {messaggio}</Text>
</Row> </Row>
</Section> </Section>
<Section className="mb-5"> <Section className="mb-5">
<Button <Button
className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900" className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/annuncio/${codice}`} href={`${process.env.NEXT_PUBLIC_BASE_URL}/annuncio/${codice}`}
> >
Vai all&apos;annuncio Vai all&apos;annuncio
</Button> </Button>
</Section> </Section>
</> </>
</Base> </Base>
); );
}; };
export default ContattoAnnuncioEmail; export default ContattoAnnuncioEmail;

View file

@ -1,71 +1,71 @@
import { Heading, Row, Section, Text } from "@react-email/components"; import { Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type ContattoInteressato_NewMail = { export type ContattoInteressato_NewMail = {
mailType: "emailContattoInteressato"; mailType: "emailContattoInteressato";
props: EmailContattoInteressatoProps; props: EmailContattoInteressatoProps;
}; };
type EmailContattoInteressatoProps = { type EmailContattoInteressatoProps = {
nome_cognome: string; nome_cognome: string;
nome_cognome_utente: string; nome_cognome_utente: string;
telefono: string; telefono: string;
indirizzo: string | null; indirizzo: string | null;
sesso: string | null; sesso: string | null;
}; };
const EmailContattoInteressato = ({ const EmailContattoInteressato = ({
nome_cognome, nome_cognome,
nome_cognome_utente, nome_cognome_utente,
telefono, telefono,
indirizzo, indirizzo,
sesso, sesso,
}: EmailContattoInteressatoProps) => { }: EmailContattoInteressatoProps) => {
const sig = sesso const sig = sesso
? sesso == "M" ? sesso === "M"
? "Il Sig." ? "Il Sig."
: "La Sig.ra" : "La Sig.ra"
: "Il/La Sig./Sig.ra"; : "Il/La Sig./Sig.ra";
return ( return (
<Base preview="Contatto Email"> <Base preview="Contatto Email">
<> <>
<Heading className="text-center text-3xl leading-8"> <Heading className="text-center text-3xl leading-8">
Richiesta di contatto Richiesta di contatto
</Heading> </Heading>
<Section className="px-5 text-left md:px-12"> <Section className="px-5 text-left md:px-12">
<Row> <Row>
<Text className="text-base"> <Text className="text-base">
Gentile proprietario {nome_cognome}, le comunichiamo che a breve Gentile proprietario {nome_cognome}, le comunichiamo che a breve
la contatterà {sig} {nome_cognome_utente} (tel: {telefono}), la contatterà {sig} {nome_cognome_utente} (tel: {telefono}),
cliente Arca abbonato tramite il nostro servizio online cliente Arca abbonato tramite il nostro servizio online
Infoalloggi.it interessato a vedere il Suo immobile{" "} Infoalloggi.it interessato a vedere il Suo immobile{" "}
{indirizzo ? "di " + indirizzo : ""}. {indirizzo ? `di ${indirizzo}` : ""}.
</Text> </Text>
<Text className="text-base"> <Text className="text-base">
Per ulteriori informazioni e chiarimenti non esiti a contattarci Per ulteriori informazioni e chiarimenti non esiti a contattarci
all&apos;indirizzo{" "} all&apos;indirizzo{" "}
<a href="mailto:web@infoalloggi.it" rel="noopener noreferrer"> <a href="mailto:web@infoalloggi.it" rel="noopener noreferrer">
web@infoalloggi.it web@infoalloggi.it
</a>{" "} </a>{" "}
oppure tramite tel. 04241760915. oppure tramite tel. 04241760915.
</Text> </Text>
<Text className="text-base">Cordiali saluti</Text> <Text className="text-base">Cordiali saluti</Text>
<br /> <br />
<Text className="text-xs"> <Text className="text-xs">
{" "} {" "}
Le informazioni contenute in questo messaggio di posta elettronica Le informazioni contenute in questo messaggio di posta elettronica
sono riservate confidenziali e ne è vietata la diffusione in sono riservate confidenziali e ne è vietata la diffusione in
qualunque modo eseguita. Qualora Lei non fosse la persona a cui il qualunque modo eseguita. Qualora Lei non fosse la persona a cui il
presente messaggio è destinato, La invitiamo gentilmente ad presente messaggio è destinato, La invitiamo gentilmente ad
eliminarlo dopo averne dato tempestiva comunicazione al mittente e eliminarlo dopo averne dato tempestiva comunicazione al mittente e
a non utilizzare in alcun caso il suo contenuto. Qualsiasi a non utilizzare in alcun caso il suo contenuto. Qualsiasi
utilizzo non autorizzato di questo messaggio e dei suoi eventuali utilizzo non autorizzato di questo messaggio e dei suoi eventuali
allegati espone il responsabile alle relative conseguenze civili e allegati espone il responsabile alle relative conseguenze civili e
penali. penali.
</Text> </Text>
</Row> </Row>
</Section> </Section>
</> </>
</Base> </Base>
); );
}; };
export default EmailContattoInteressato; export default EmailContattoInteressato;

View file

@ -1,102 +1,102 @@
import { import {
Button, Button,
Heading, Heading,
Img, Img,
Link, Link,
Row, Row,
Section, Section,
Text, Text,
} from "@react-email/components"; } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type OnboardingServizio_NewMail = { export type OnboardingServizio_NewMail = {
mailType: "onboardingServizio"; mailType: "onboardingServizio";
props: OnboardingServizioProps; props: OnboardingServizioProps;
}; };
type OnboardingServizioProps = { type OnboardingServizioProps = {
token: string; token: string;
nome: string; nome: string;
annunci?: { annunci?: {
titolo: string | null; titolo: string | null;
immagine: string | null; immagine: string | null;
codice: string; codice: string;
prezzo: number; prezzo: number;
}[]; }[];
}; };
const OnboardingServizio = ({ const OnboardingServizio = ({
token, token,
nome, nome,
annunci, annunci,
}: OnboardingServizioProps) => { }: OnboardingServizioProps) => {
return ( return (
<Base preview="Procedi ora su Infoalloggi.it"> <Base preview="Procedi ora su Infoalloggi.it">
<> <>
<Heading className="text-center text-3xl leading-8"> <Heading className="text-center text-3xl leading-8">
Procedi ora su Infoalloggi.it Procedi ora su Infoalloggi.it
</Heading> </Heading>
<Section className="px-5 text-left text-base md:px-12"> <Section className="px-5 text-left text-base md:px-12">
<Row> <Row>
<Text> <Text>
Ciao {nome},<br /> Ciao {nome},<br />
Accedi ora al servizio di ricerca Infoalloggi, dove potrai andare Accedi ora al servizio di ricerca Infoalloggi, dove potrai andare
a contattare direttamente i proprietari degli immobili a contattare direttamente i proprietari degli immobili
disponibili. disponibili.
</Text> </Text>
</Row> </Row>
<Section className="mb-5"> <Section className="mb-5">
<Button <Button
className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900" className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/servizio/pre-onboard/${token}`} href={`${process.env.NEXT_PUBLIC_BASE_URL}/servizio/pre-onboard/${token}`}
> >
Vai al servizio Vai al servizio
</Button> </Button>
</Section> </Section>
<Section> <Section>
{annunci?.map((annuncio, index) => ( {annunci?.map((annuncio, index) => (
<> <>
<Text className="font-semibold"> <Text className="font-semibold">
Annunci selezionati per te: Annunci selezionati per te:
</Text> </Text>
<Section className="mb-[30px]" key={index}> <Section className="mb-[30px]" key={index}>
<Row className="mb-[12px]"> <Row className="mb-[12px]">
<Img <Img
src={`${process.env.NEXT_PUBLIC_BASE_URL}/go-api/images/get/${annuncio.immagine}`} src={`${process.env.NEXT_PUBLIC_BASE_URL}/go-api/images/get/${annuncio.immagine}`}
width="100%" width="100%"
height="140px" height="140px"
alt={`Annuncio image - ${annuncio.codice}`} alt={`Annuncio image - ${annuncio.codice}`}
className="block w-[240px] rounded-[4px] object-cover object-center" className="block w-[240px] rounded-[4px] object-cover object-center"
/> />
</Row> </Row>
<Row className="block w-full sm:hidden"> <Row className="block w-full sm:hidden">
<div className="mb-[5px] flex h-[24px] w-fit items-center justify-center gap-1 rounded-full bg-indigo-600 px-2 text-[12px] leading-none font-semibold text-white"> <div className="mb-[5px] flex h-[24px] w-fit items-center justify-center gap-1 rounded-full bg-indigo-600 px-2 text-[12px] leading-none font-semibold text-white">
<strong>Codice: </strong> {annuncio.codice} <strong>Codice: </strong> {annuncio.codice}
</div> </div>
<Heading <Heading
as="h2" as="h2"
className="mt-0 mb-[6px] text-[16px] leading-none font-bold" className="mt-0 mb-[6px] text-[16px] leading-none font-bold"
> >
{annuncio.titolo || "N/A"} {annuncio.titolo || "N/A"}
</Heading> </Heading>
<Text className="m-0 text-[14px] leading-[24px] text-gray-500"> <Text className="m-0 text-[14px] leading-[24px] text-gray-500">
<strong>Prezzo:</strong> <strong>Prezzo:</strong>
{(annuncio.prezzo / 1e2).toFixed(2)} {(annuncio.prezzo / 1e2).toFixed(2)}
</Text> </Text>
<Link <Link
href={`${process.env.NEXT_PUBLIC_BASE_URL}/annuncio/${annuncio.codice}`} href={`${process.env.NEXT_PUBLIC_BASE_URL}/annuncio/${annuncio.codice}`}
className="block text-[14px] font-semibold text-indigo-600 no-underline" className="block text-[14px] font-semibold text-indigo-600 no-underline"
> >
Scheda dell&apos;annuncio Scheda dell&apos;annuncio
</Link> </Link>
</Row> </Row>
</Section> </Section>
</> </>
))} ))}
</Section> </Section>
</Section> </Section>
</> </>
</Base> </Base>
); );
}; };
export default OnboardingServizio; export default OnboardingServizio;

View file

@ -1,42 +1,42 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type PagamentoConferma_NewMail = { export type PagamentoConferma_NewMail = {
mailType: "pagamentoConferma"; mailType: "pagamentoConferma";
props: PagamentoConfermaProps; props: PagamentoConfermaProps;
}; };
type PagamentoConfermaProps = { type PagamentoConfermaProps = {
receiptHref: string; receiptHref: string;
}; };
const PagamentoConferma = ({ receiptHref }: PagamentoConfermaProps) => { const PagamentoConferma = ({ receiptHref }: PagamentoConfermaProps) => {
return ( return (
<Base preview="Conferma Pagamento"> <Base preview="Conferma Pagamento">
<> <>
<Heading className="text-center text-3xl leading-8"> <Heading className="text-center text-3xl leading-8">
Conferma Pagamento Conferma Pagamento
</Heading> </Heading>
<Section className="px-5 text-left text-base md:px-12"> <Section className="px-5 text-left text-base md:px-12">
<Row> <Row>
<Text> <Text>
Gentile Cliente, la informiamo che il pagamento è stato confermato Gentile Cliente, la informiamo che il pagamento è stato confermato
con successo. Procederemo ad attivare i servizi acquistati se non con successo. Procederemo ad attivare i servizi acquistati se non
lo fossero già. Può controllare lo stato del suo account nella lo fossero già. Può controllare lo stato del suo account nella
sezione dedicata. sezione dedicata.
</Text> </Text>
</Row> </Row>
<Section className="mb-5"> <Section className="mb-5">
<Button <Button
className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900" className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900"
href={receiptHref} href={receiptHref}
> >
Vai al tuo account Vai al tuo account
</Button> </Button>
</Section> </Section>
</Section> </Section>
</> </>
</Base> </Base>
); );
}; };
export default PagamentoConferma; export default PagamentoConferma;

View file

@ -1,39 +1,39 @@
import { Heading, Row, Section, Text, Button } from "@react-email/components"; import { Button, Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base"; import Base from "./base";
export type PwResetLink_NewMail = { export type PwResetLink_NewMail = {
mailType: "pwResetLink"; mailType: "pwResetLink";
props: PwResetLinkProps; props: PwResetLinkProps;
}; };
type PwResetLinkProps = { type PwResetLinkProps = {
resetlink: string; resetlink: string;
}; };
const PwResetLink = ({ resetlink }: PwResetLinkProps) => { const PwResetLink = ({ resetlink }: PwResetLinkProps) => {
return ( return (
<Base preview="Link Reset Password"> <Base preview="Link Reset Password">
<> <>
<Heading className="text-center text-3xl leading-8"> <Heading className="text-center text-3xl leading-8">
Link Reset Password Link Reset Password
</Heading> </Heading>
<Section className="px-5 text-left text-base md:px-12"> <Section className="px-5 text-left text-base md:px-12">
<Row> <Row>
<Text> <Text>
Abbiamo ricevuto la tua richiesta di reset password, clicca sul Abbiamo ricevuto la tua richiesta di reset password, clicca sul
link sottostante per procedere. link sottostante per procedere.
</Text> </Text>
</Row> </Row>
<Section className="mb-5"> <Section className="mb-5">
<Button <Button
className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900" className="mx-auto box-border inline-flex h-10 items-center justify-center rounded-md bg-neutral-100 px-4 py-2 text-center align-middle font-semibold whitespace-nowrap text-neutral-900"
href={resetlink} href={resetlink}
> >
Clicca qui per il reset della password Clicca qui per il reset della password
</Button> </Button>
</Section> </Section>
</Section> </Section>
</> </>
</Base> </Base>
); );
}; };
export default PwResetLink; export default PwResetLink;

View file

@ -1,152 +0,0 @@
import react from "eslint-plugin-react";
import typescriptEslint from "@typescript-eslint/eslint-plugin";
import pluginQuery from "@tanstack/eslint-plugin-query";
import kyselyRules from "./kyselyRules/plugin.js";
import tsParser from "@typescript-eslint/parser";
import path from "node:path";
import { fileURLToPath } from "node:url";
import js from "@eslint/js";
import noRelativeImportPaths from "eslint-plugin-no-relative-import-paths";
import { FlatCompat } from "@eslint/eslintrc";
import jsxA11y from "eslint-plugin-jsx-a11y";
import eslintPluginBetterTailwindcss from "eslint-plugin-better-tailwindcss";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
...pluginQuery.configs["flat/recommended"],
{
ignores: [
"**/.next",
"**/out",
"**/public",
"**/node_modules/",
"**/check-env-vars.js",
"**/headers/",
"**/schemas/",
"**/kyselyRules/",
"prettier.config.cjs",
"postcss.config.cjs",
".kanelrc.js",
],
},
...compat.extends(
"eslint:recommended",
"plugin:react/recommended",
"plugin:@next/next/recommended",
"plugin:@typescript-eslint/recommended",
"plugin:@typescript-eslint/recommended-type-checked",
"plugin:@typescript-eslint/stylistic-type-checked",
"plugin:jsx-a11y/recommended",
),
{
plugins: {
react,
"@typescript-eslint": typescriptEslint,
"better-tailwindcss": eslintPluginBetterTailwindcss,
"jsx-a11y": jsxA11y,
kyselyRules: kyselyRules,
//"no-relative-import-paths": noRelativeImportPaths,
},
languageOptions: {
parser: tsParser,
ecmaVersion: 5,
sourceType: "script",
parserOptions: {
project: true,
tsconfigRootDir: "./",
},
},
linterOptions: {
reportUnusedDisableDirectives: true,
},
settings: {
react: {
version: "detect",
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
project: "./tsconfig.json",
},
},
"better-tailwindcss": {
entryPoint: "src/styles/globals.css",
},
},
rules: {
"react/react-in-jsx-scope": "off",
"react-hooks/exhaustive-deps": "off",
"react/prop-types": "off",
"@typescript-eslint/ban-types": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/array-type": "off",
"@typescript-eslint/no-redundant-type-constituents": "warn",
"@typescript-eslint/consistent-type-definitions": "off",
"@typescript-eslint/prefer-optional-chain": "off",
"@typescript-eslint/prefer-nullish-coalescing": "off",
"@typescript-eslint/no-empty-interface": "off",
"@typescript-eslint/unbound-method": "off",
"@typescript-eslint/consistent-type-imports": [
"warn",
{
prefer: "type-imports",
fixStyle: "inline-type-imports",
},
],
"@typescript-eslint/no-unused-vars": [
"warn",
{
argsIgnorePattern: "^_",
},
],
"@typescript-eslint/require-await": "off",
"@typescript-eslint/switch-exhaustiveness-check": "warn",
"@typescript-eslint/no-misused-promises": [
"error",
{
checksVoidReturn: {
attributes: false,
},
},
],
"@next/next/no-img-element": "warn",
"kyselyRules/enforce-select": "error",
"kyselyRules/enforce-where": "error",
"kyselyRules/enforce-null": "error",
...eslintPluginBetterTailwindcss.configs["recommended-warn"]?.rules,
...eslintPluginBetterTailwindcss.configs["recommended-error"]?.rules,
"better-tailwindcss/enforce-consistent-line-wrapping": "off",
"better-tailwindcss/enforce-consistent-class-order": "off",
},
},
{
//ignores: ["**/emails/**/*.tsx", "next.config.js"],
files: ["src/**/*.tsx", "src/**/*.ts"],
plugins: {
"no-relative-import-paths": noRelativeImportPaths,
},
rules: {
"no-relative-import-paths/no-relative-import-paths": [
"warn",
{ rootDir: "src", prefix: "~" },
],
},
},
];

View file

@ -1,57 +1,40 @@
{ {
"$schema": "https://unpkg.com/knip@5/schema.json", "$schema": "https://unpkg.com/knip@5/schema.json",
"project": [ "project": ["src/**", "**", "!src/schemas/public/*.ts", "!emails/**"],
"src/**", "ignore": [
"**", ".kanelrc.js",
"!src/schemas/public/*.ts", "TypeHelpers.type.ts",
"!emails/**" "src/utils/api.ts",
], "src/server/api/trpc.ts",
"ignore": [ "check-env-vars.js",
".kanelrc.js", "src/components/ui/*",
".eslintrc.json", "src/hooks/coordDistance.ts",
"TypeHelpers.type.ts", "src/hooks/useMobile.tsx",
"src/utils/api.ts", "src/lib/nossr.tsx",
"src/server/api/trpc.ts", "src/lib/useIsTyping.ts",
"check-env-vars.js", "src/middlewares/apis_middleware.ts",
"src/components/ui/*", "src/server/auth.ts",
"src/hooks/coordDistance.ts", "src/utils/get-media-url.ts",
"src/hooks/useMobile.tsx", "src/utils/utils.ts"
"src/lib/nossr.tsx", ],
"src/lib/useIsTyping.ts", "ignoreBinaries": ["stripe"],
"src/middlewares/apis_middleware.ts", "ignoreDependencies": [
"src/server/auth.ts", "kysely-plugin-serialize",
"src/utils/get-media-url.ts", "sharp",
"src/utils/utils.ts" "uuid",
], "@react-email/components",
"ignoreBinaries": [ "@types/uuid",
"stripe" "kanel-kysely",
], "npm-run-all",
"ignoreDependencies": [ "@react-email/preview-server",
"kysely-plugin-serialize", "@tailwindcss/forms",
"sharp", "@tailwindcss/typography",
"uuid", "tailwind-scrollbar",
"@react-email/components", "tw-animate-css",
"@eslint/compat", "@radix-ui/react-dialog",
"@next/eslint-plugin-next", "@radix-ui/react-label",
"@prettier/sync", "@radix-ui/react-slot",
"@types/eslint", "@radix-ui/react-tooltip",
"@types/uuid", "@radix-ui/react-popover"
"eslint-config-next", ]
"eslint-import-resolver-typescript", }
"eslint-plugin-import",
"eslint-plugin-react-hooks",
"kanel-kysely",
"npm-run-all",
"typescript-eslint",
"@react-email/preview-server",
"@tailwindcss/forms",
"@tailwindcss/typography",
"tailwind-scrollbar",
"tw-animate-css",
"@radix-ui/react-dialog",
"@radix-ui/react-label",
"@radix-ui/react-slot",
"@radix-ui/react-tooltip",
"@radix-ui/react-popover"
]
}

View file

@ -7,88 +7,86 @@ import { env } from "node:process";
/** @type {import("next").NextConfig} */ /** @type {import("next").NextConfig} */
const nextConfig = { const nextConfig = {
output: env.NODE_ENV === "production" ? "standalone" : undefined, // This is for docker builds output: env.NODE_ENV === "production" ? "standalone" : undefined, // This is for docker builds
reactStrictMode: true, reactStrictMode: true,
compiler: { compiler: {
removeConsole: false, removeConsole: false,
}, },
i18n: { i18n: {
locales: ["it", "en"], locales: ["it", "en"],
defaultLocale: "it", defaultLocale: "it",
}, },
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: "https",
hostname: "*.googleusercontent.com", hostname: "*.googleusercontent.com",
port: "", port: "",
pathname: "**", pathname: "**",
}, },
{ {
hostname: env.BACKENDSERVER_URL || "localhost:1323", hostname: env.BACKENDSERVER_URL || "localhost:1323",
}, },
], ],
minimumCacheTTL: 86400, // 1 day in seconds minimumCacheTTL: 86400, // 1 day in seconds
}, },
typescript: { typescript: {
ignoreBuildErrors: true, ignoreBuildErrors: true,
}, },
eslint: {
ignoreDuringBuilds: true, devIndicators: false,
}, rewrites: async () => {
devIndicators: false, return [
rewrites: async () => { {
return [ source: "/api/panel",
{ destination: env.NODE_ENV === "production" ? "/404" : "/api/panel",
source: "/api/panel", },
destination: env.NODE_ENV === "production" ? "/404" : "/api/panel", {
}, source: "/go-api/images/get/:slug*",
{ destination: `${env.BACKENDSERVER_URL}/images/get/:slug*`,
source: "/go-api/images/get/:slug*", },
destination: env.BACKENDSERVER_URL + "/images/get/:slug*", {
}, source: "/go-api/storage/get/:slug*",
{ destination: `${env.BACKENDSERVER_URL}/storage/get/:slug*`,
source: "/go-api/storage/get/:slug*", has: [
destination: env.BACKENDSERVER_URL + "/storage/get/:slug*", { type: "cookie", key: "access_token" },
has: [ {
{ type: "cookie", key: "access_token" }, type: "query",
{ key: "token",
type: "query", value:
key: "token", "(?<token>^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$)",
value: },
"(?<token>^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$)", ],
}, },
], {
}, source: "/go-api/storage/upload:slug*",
{ destination: `${env.BACKENDSERVER_URL}/storage/upload:slug*`,
source: "/go-api/storage/upload:slug*", has: [
destination: env.BACKENDSERVER_URL + "/storage/upload:slug*", { type: "cookie", key: "access_token" },
has: [ {
{ type: "cookie", key: "access_token" }, type: "query",
{ key: "token",
type: "query", value:
key: "token", "(?<token>^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$)",
value: },
"(?<token>^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$)", ],
}, },
], ];
}, },
]; headers: async () => {
}, return [
headers: async () => { {
return [ source: "/:path*",
{ headers: [
source: "/:path*", {
headers: [ key: "X-Frame-Options",
{ value: "",
key: "X-Frame-Options", },
value: "", ],
}, },
], ];
}, },
];
},
}; };
export default nextConfig; export default nextConfig;

File diff suppressed because it is too large Load diff

View file

@ -101,17 +101,9 @@
}, },
"devDependencies": { "devDependencies": {
"@biomejs/biome": "2.2.2", "@biomejs/biome": "2.2.2",
"@eslint/compat": "^1.3.1",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "^9.32.0",
"@hookform/devtools": "^4.4.0", "@hookform/devtools": "^4.4.0",
"@next/eslint-plugin-next": "^15.4.5",
"@prettier/sync": "^0.6.1",
"@react-email/preview-server": "^4.2.8", "@react-email/preview-server": "^4.2.8",
"@tanstack/eslint-plugin-query": "^5.83.1",
"@total-typescript/ts-reset": "^0.6.1", "@total-typescript/ts-reset": "^0.6.1",
"@types/eslint": "^9.6.1",
"@types/eslint-plugin-jsx-a11y": "^6.10.0",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.20", "@types/leaflet": "^1.9.20",
"@types/node": "^24.2.0", "@types/node": "^24.2.0",
@ -122,27 +114,13 @@
"@types/react-dom": "^19.1.7", "@types/react-dom": "^19.1.7",
"@types/react-is": "^19.0.0", "@types/react-is": "^19.0.0",
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.39.0",
"@typescript-eslint/parser": "^8.39.0",
"cross-env": "^10.0.0", "cross-env": "^10.0.0",
"eslint": "^9.32.0",
"eslint-config-next": "^15.4.5",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-better-tailwindcss": "^3.7.4",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-no-relative-import-paths": "^1.6.1",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"jiti": "^2.5.1", "jiti": "^2.5.1",
"kanel": "^3.14.2", "kanel": "^3.14.2",
"kanel-kysely": "^0.7.1", "kanel-kysely": "^0.7.1",
"knip": "^5.62.0", "knip": "^5.62.0",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"prettier": "^3.6.2", "typescript": "^5.9.2"
"prettier-plugin-classnames": "^0.8.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.0"
}, },
"ct3aMetadata": { "ct3aMetadata": {
"initVersion": "7.9.0" "initVersion": "7.9.0"

View file

@ -1,4 +1,4 @@
/* eslint-env node */
const config = { const config = {
plugins: { plugins: {

View file

@ -1,10 +0,0 @@
/* eslint-env node */
/** @type {import("prettier").Config & import('prettier-plugin-tailwindcss').PluginOptions} */
const config = {
printWidth: 80,
plugins: ["prettier-plugin-classnames", "prettier-plugin-tailwindcss"],
customAttributes: ["className"],
customFunctions: ["classNames"],
};
module.exports = config;

View file

@ -1,22 +1,22 @@
import { Suspense } from "react"; import { Suspense } from "react";
import Annunci from "~/_app/ui/annunci";
import { getDataFromDB } from "~/_app/lib/annunci"; import { getDataFromDB } from "~/_app/lib/annunci";
import AnnunciList from "~/_app/ui/annunci";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
export default function TestAppPage() { export default function TestAppPage() {
const annunci = getDataFromDB(); const annunci = getDataFromDB();
return ( return (
<div className="p-8"> <div className="p-8">
<h1 className="mb-4 text-2xl font-bold">Test App Page</h1> <h1 className="mb-4 text-2xl font-bold">Test App Page</h1>
<div className="rounded-lg border bg-gray-50 p-4"> <div className="rounded-lg border bg-gray-50 p-4">
<h2 className="mb-2 text-lg font-semibold">Data from Database:</h2> <h2 className="mb-2 text-lg font-semibold">Data from Database:</h2>
<Suspense fallback={<LoadingPage />}> <Suspense fallback={<LoadingPage />}>
<Annunci annunci={annunci} /> <AnnunciList annunci={annunci} />
</Suspense> </Suspense>
</div> </div>
</div> </div>
); );
} }

View file

@ -3,17 +3,17 @@ import { use } from "react";
import type { Annunci } from "~/schemas/public/Annunci"; import type { Annunci } from "~/schemas/public/Annunci";
type AnnunciProps = { type AnnunciProps = {
annunci: Promise<Pick<Annunci, "codice">[]>; annunci: Promise<Pick<Annunci, "codice">[]>;
}; };
export default function Annunci({ annunci }: AnnunciProps) { export default function AnnunciList({ annunci }: AnnunciProps) {
const allAnnunci = use(annunci); const allAnnunci = use(annunci);
return ( return (
<ul> <ul>
{allAnnunci.map((ann, i) => ( {allAnnunci.map((ann, i) => (
<li key={i}>{ann.codice}</li> <li key={i}>{ann.codice}</li>
))} ))}
</ul> </ul>
); );
} }

View file

@ -1,238 +1,238 @@
import { import {
Accessibility, Accessibility,
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Baby, Baby,
BellRing, BellRing,
Building, Building,
Building2, Building2,
CircleDot, CircleDot,
CircleParking, CircleParking,
CirclePlus, CirclePlus,
CircleX, CircleX,
Cog, Cog,
Contact, Contact,
Cross, Cross,
Dot, Dot,
Euro, Euro,
FerrisWheel, FerrisWheel,
Film, Film,
GraduationCap, GraduationCap,
Heart, Heart,
Heater, Heater,
House, House,
Info, Info,
LayoutDashboard, LayoutDashboard,
Mail, type LucideProps,
MessageSquareText, Mail,
MessagesSquare, MessageSquareText,
Milestone, MessagesSquare,
Paperclip, Milestone,
Pointer, Paperclip,
Search, Pointer,
ShoppingCart, Search,
SlidersHorizontal, ShoppingCart,
Sofa, SlidersHorizontal,
Star, Sofa,
Target, Star,
ThermometerSun, Target,
ThumbsUp, ThermometerSun,
Trees, ThumbsUp,
TriangleAlert, Trees,
User, TriangleAlert,
Users, User,
Wifi, Users,
type LucideProps, Wifi,
} from "lucide-react"; } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import toast from "react-hot-toast";
import type { import type {
ComponentPropsWithoutRef, ComponentPropsWithoutRef,
ForwardRefExoticComponent, ForwardRefExoticComponent,
RefAttributes, RefAttributes,
} from "react"; } from "react";
import toast from "react-hot-toast";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
const iconOptions = [ const iconOptions = [
"heater", "heater",
"error", "error",
"warning", "warning",
"sofa", "sofa",
"building", "building",
"building2", "building2",
"wifi", "wifi",
"accessibility", "accessibility",
"baby", "baby",
"thermometer-sun", "thermometer-sun",
"circle-plus", "circle-plus",
"circle-dot", "circle-dot",
"dot", "dot",
"cog", "cog",
"check", "check",
"search", "search",
"contact", "contact",
"euro", "euro",
"dashboard", "dashboard",
"settings", "settings",
"user", "user",
"users", "users",
"messages", "messages",
"message", "message",
"arrowR", "arrowR",
"arrowL", "arrowL",
"attachment", "attachment",
"cart", "cart",
"star", "star",
"house", "house",
"heart", "heart",
"info", "info",
"mail", "mail",
"school", "school",
"cross", "cross",
"park", "park",
"parking", "parking",
"film", "film",
"milestone", "milestone",
"entertainment", "entertainment",
"circle-help", "circle-help",
"bell", "bell",
"target", "target",
"thumbs-up", "thumbs-up",
] as const; ] as const;
export type IconType = (typeof iconOptions)[number]; export type IconType = (typeof iconOptions)[number];
type LucidePropsType = ForwardRefExoticComponent< type LucidePropsType = ForwardRefExoticComponent<
Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement> Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>
>; >;
interface IconMatrixProps extends ComponentPropsWithoutRef<LucidePropsType> { interface IconMatrixProps extends ComponentPropsWithoutRef<LucidePropsType> {
type: IconType; type: IconType;
} }
export const IconMatrix = ({ type, ...rest }: IconMatrixProps) => { export const IconMatrix = ({ type, ...rest }: IconMatrixProps) => {
switch (type) { switch (type) {
case "heater": case "heater":
return <Heater {...rest} />; return <Heater {...rest} />;
case "error": case "error":
return <CircleX {...rest} />; return <CircleX {...rest} />;
case "warning": case "warning":
return <TriangleAlert {...rest} />; return <TriangleAlert {...rest} />;
case "sofa": case "sofa":
return <Sofa {...rest} />; return <Sofa {...rest} />;
case "building": case "building":
return <Building {...rest} />; return <Building {...rest} />;
case "building2": case "building2":
return <Building2 {...rest} />; return <Building2 {...rest} />;
case "wifi": case "wifi":
return <Wifi {...rest} />; return <Wifi {...rest} />;
case "accessibility": case "accessibility":
return <Accessibility {...rest} />; return <Accessibility {...rest} />;
case "baby": case "baby":
return <Baby {...rest} />; return <Baby {...rest} />;
case "thermometer-sun": case "thermometer-sun":
return <ThermometerSun {...rest} />; return <ThermometerSun {...rest} />;
case "circle-plus": case "circle-plus":
return <CirclePlus {...rest} />; return <CirclePlus {...rest} />;
case "circle-dot": case "circle-dot":
return <CircleDot {...rest} />; return <CircleDot {...rest} />;
case "dot": case "dot":
return <Dot {...rest} />; return <Dot {...rest} />;
case "cog": case "cog":
return <Cog {...rest} />; return <Cog {...rest} />;
case "check": case "check":
return <CheckCircleIcon {...rest} />; return <CheckCircleIcon {...rest} />;
case "search": case "search":
return <Search {...rest} />; return <Search {...rest} />;
case "contact": case "contact":
return <Contact {...rest} />; return <Contact {...rest} />;
case "euro": case "euro":
return <Euro {...rest} />; return <Euro {...rest} />;
case "dashboard": case "dashboard":
return <LayoutDashboard {...rest} />; return <LayoutDashboard {...rest} />;
case "settings": case "settings":
return <SlidersHorizontal {...rest} />; return <SlidersHorizontal {...rest} />;
case "user": case "user":
return <User {...rest} />; return <User {...rest} />;
case "users": case "users":
return <Users {...rest} />; return <Users {...rest} />;
case "messages": case "messages":
return <MessagesSquare {...rest} />; return <MessagesSquare {...rest} />;
case "message": case "message":
return <MessageSquareText {...rest} />; return <MessageSquareText {...rest} />;
case "arrowR": case "arrowR":
return <ArrowRight {...rest} />; return <ArrowRight {...rest} />;
case "arrowL": case "arrowL":
return <ArrowLeft {...rest} />; return <ArrowLeft {...rest} />;
case "attachment": case "attachment":
return <Paperclip {...rest} />; return <Paperclip {...rest} />;
case "cart": case "cart":
return <ShoppingCart {...rest} />; return <ShoppingCart {...rest} />;
case "star": case "star":
return <Star {...rest} />; return <Star {...rest} />;
case "house": case "house":
return <House {...rest} />; return <House {...rest} />;
case "heart": case "heart":
return <Heart {...rest} />; return <Heart {...rest} />;
case "info": case "info":
return <Info {...rest} />; return <Info {...rest} />;
case "mail": case "mail":
return <Mail {...rest} />; return <Mail {...rest} />;
case "school": case "school":
return <GraduationCap {...rest} />; return <GraduationCap {...rest} />;
case "cross": case "cross":
return <Cross {...rest} />; return <Cross {...rest} />;
case "park": case "park":
return <Trees {...rest} />; return <Trees {...rest} />;
case "parking": case "parking":
return <CircleParking {...rest} />; return <CircleParking {...rest} />;
case "film": case "film":
return <Film {...rest} />; return <Film {...rest} />;
case "milestone": case "milestone":
return <Milestone {...rest} />; return <Milestone {...rest} />;
case "entertainment": case "entertainment":
return <FerrisWheel {...rest} />; return <FerrisWheel {...rest} />;
case "circle-help": case "circle-help":
return <QuestionMarkCircleIcon {...rest} />; return <QuestionMarkCircleIcon {...rest} />;
case "bell": case "bell":
return <BellRing {...rest} />; return <BellRing {...rest} />;
case "target": case "target":
return <Target {...rest} />; return <Target {...rest} />;
case "thumbs-up": case "thumbs-up":
return <ThumbsUp {...rest} />; return <ThumbsUp {...rest} />;
} }
}; };
export const IconMatrixTooltip = () => { export const IconMatrixTooltip = () => {
return ( return (
<TooltipProvider delayDuration={300}> <TooltipProvider delayDuration={300}>
<Tooltip> <Tooltip>
<TooltipTrigger className="flex flex-row gap-1" type="button"> <TooltipTrigger className="flex flex-row gap-1" type="button">
Lista Icone <Pointer className="size-4" /> Lista Icone <Pointer className="size-4" />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="grid grid-cols-4 gap-2"> <div className="grid grid-cols-4 gap-2">
{iconOptions.map((icon) => ( {iconOptions.map((icon) => (
<button <button
type="button" type="button"
onClick={async () => { onClick={async () => {
await navigator.clipboard.writeText(icon); await navigator.clipboard.writeText(icon);
toast("Copiato", { icon: "📋" }); toast("Copiato", { icon: "📋" });
}} }}
key={icon} key={icon}
className="bg-muted flex flex-col items-center rounded-xl p-2 hover:opacity-75" className="bg-muted flex flex-col items-center rounded-xl p-2 hover:opacity-75"
> >
<IconMatrix type={icon} className="size-6" /> <IconMatrix type={icon} className="size-6" />
<span className="text-sm">{icon}</span> <span className="text-sm">{icon}</span>
</button> </button>
))} ))}
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
); );
}; };
/* /*
type IRP_readOnly = { type IRP_readOnly = {
@ -305,70 +305,74 @@ export const IconRatingComonent = ({
}; };
*/ */
export const ShieldExclamationIcon = ({ export const ShieldExclamationIcon = ({
className, className,
}: { }: {
className?: string; className?: string;
}) => ( }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className={className} className={className}
> >
<path <title>Shield</title>
fillRule="evenodd" <path
d="M11.484 2.17a.75.75 0 0 1 1.032 0 11.209 11.209 0 0 0 7.877 3.08.75.75 0 0 1 .722.515 12.74 12.74 0 0 1 .635 3.985c0 5.942-4.064 10.933-9.563 12.348a.749.749 0 0 1-.374 0C6.314 20.683 2.25 15.692 2.25 9.75c0-1.39.223-2.73.635-3.985a.75.75 0 0 1 .722-.516l.143.001c2.996 0 5.718-1.17 7.734-3.08ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75ZM12 15a.75.75 0 0 0-.75.75v.008c0 .414.336.75.75.75h.008a.75.75 0 0 0 .75-.75v-.008a.75.75 0 0 0-.75-.75H12Z" fillRule="evenodd"
clipRule="evenodd" d="M11.484 2.17a.75.75 0 0 1 1.032 0 11.209 11.209 0 0 0 7.877 3.08.75.75 0 0 1 .722.515 12.74 12.74 0 0 1 .635 3.985c0 5.942-4.064 10.933-9.563 12.348a.749.749 0 0 1-.374 0C6.314 20.683 2.25 15.692 2.25 9.75c0-1.39.223-2.73.635-3.985a.75.75 0 0 1 .722-.516l.143.001c2.996 0 5.718-1.17 7.734-3.08ZM12 8.25a.75.75 0 0 1 .75.75v3.75a.75.75 0 0 1-1.5 0V9a.75.75 0 0 1 .75-.75ZM12 15a.75.75 0 0 0-.75.75v.008c0 .414.336.75.75.75h.008a.75.75 0 0 0 .75-.75v-.008a.75.75 0 0 0-.75-.75H12Z"
/> clipRule="evenodd"
</svg> />
</svg>
); );
export const QuestionMarkCircleIcon = ({ export const QuestionMarkCircleIcon = ({
className, className,
}: { }: {
className?: string; className?: string;
}) => ( }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className={className} className={className}
> >
<path <title>QuestionMark</title>
fillRule="evenodd" <path
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.378-3.917c-.89-.777-2.366-.777-3.255 0a.75.75 0 0 1-.988-1.129c1.454-1.272 3.776-1.272 5.23 0 1.513 1.324 1.513 3.518 0 4.842a3.75 3.75 0 0 1-.837.552c-.676.328-1.028.774-1.028 1.152v.75a.75.75 0 0 1-1.5 0v-.75c0-1.279 1.06-2.107 1.875-2.502.182-.088.351-.199.503-.331.83-.727.83-1.857 0-2.584ZM12 18a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z" fillRule="evenodd"
clipRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm11.378-3.917c-.89-.777-2.366-.777-3.255 0a.75.75 0 0 1-.988-1.129c1.454-1.272 3.776-1.272 5.23 0 1.513 1.324 1.513 3.518 0 4.842a3.75 3.75 0 0 1-.837.552c-.676.328-1.028.774-1.028 1.152v.75a.75.75 0 0 1-1.5 0v-.75c0-1.279 1.06-2.107 1.875-2.502.182-.088.351-.199.503-.331.83-.727.83-1.857 0-2.584ZM12 18a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5Z"
/> clipRule="evenodd"
</svg> />
</svg>
); );
export const HomeIcon = ({ className }: { className?: string }) => ( export const HomeIcon = ({ className }: { className?: string }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
fill="none" fill="none"
viewBox="0 0 24 24" viewBox="0 0 24 24"
strokeWidth={1.5} strokeWidth={1.5}
stroke="currentColor" stroke="currentColor"
className={className} className={className}
> >
<path <title>Home</title>
strokeLinecap="round" <path
strokeLinejoin="round" strokeLinecap="round"
d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25" strokeLinejoin="round"
/> d="m2.25 12 8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"
</svg> />
</svg>
); );
const CheckCircleIcon = ({ className }: { className?: string }) => ( const CheckCircleIcon = ({ className }: { className?: string }) => (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24" viewBox="0 0 24 24"
fill="currentColor" fill="currentColor"
className={className} className={className}
> >
<path <title>CheckCircle</title>
fillRule="evenodd" <path
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z" fillRule="evenodd"
clipRule="evenodd" d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12Zm13.36-1.814a.75.75 0 1 0-1.22-.872l-3.236 4.53L9.53 12.22a.75.75 0 0 0-1.06 1.06l2.25 2.25a.75.75 0 0 0 1.14-.094l3.75-5.25Z"
/> clipRule="evenodd"
</svg> />
</svg>
); );

View file

@ -1,19 +1,19 @@
import Image from "next/image"; import Image from "next/image";
import { type ComponentProps } from "react"; import type { ComponentProps } from "react";
const fallbackImage = "/fallback-image.png"; const fallbackImage = "/fallback-image.png";
export const ImageFlbk = (props: ComponentProps<typeof Image>) => { export const ImageFlbk = (props: ComponentProps<typeof Image>) => {
return ( return (
<Image <Image
{...props} {...props}
onLoad={(e) => { onLoad={(e) => {
if (e.currentTarget.naturalWidth === 0) { if (e.currentTarget.naturalWidth === 0) {
e.currentTarget.src = fallbackImage; e.currentTarget.src = fallbackImage;
} }
}} }}
onError={(e) => { onError={(e) => {
e.currentTarget.src = fallbackImage; e.currentTarget.src = fallbackImage;
}} }}
/> />
); );
}; };

View file

@ -1,14 +1,15 @@
import { cn } from "~/lib/utils";
import {
HybridTooltip,
HybridTooltipContent,
HybridTooltipTrigger,
} from "~/components/custom_ui/HybridTooltip";
import { TooltipProvider } from "~/components/ui/tooltip";
import { IconMatrix, type IconType } from "~/components/IconComponents";
import Link from "next/link";
import { ExternalLink } from "lucide-react"; import { ExternalLink } from "lucide-react";
import Link from "next/link";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import {
HybridTooltip,
HybridTooltipContent,
HybridTooltipTrigger,
} from "~/components/custom_ui/HybridTooltip";
import { IconMatrix, type IconType } from "~/components/IconComponents";
import { TooltipProvider } from "~/components/ui/tooltip";
import { cn } from "~/lib/utils";
/* /*
<TouchProvider> <TouchProvider>
<div className="relative"> <div className="relative">
@ -19,70 +20,70 @@ import type { ReactNode } from "react";
*/ */
type ChildrenBubbleProps = { type ChildrenBubbleProps = {
children: ReactNode; children: ReactNode;
href?: undefined; href?: undefined;
txt?: undefined; txt?: undefined;
target?: undefined; target?: undefined;
}; };
type LinkBubbleProps = { type LinkBubbleProps = {
children?: undefined; children?: undefined;
href: string; href: string;
txt: string; txt: string;
target?: "_self" | "_blank" | "_parent" | "_top"; target?: "_self" | "_blank" | "_parent" | "_top";
}; };
type InformationBubbleProps = { type InformationBubbleProps = {
className?: string; className?: string;
iconClassName?: string; iconClassName?: string;
icon?: IconType; icon?: IconType;
side?: "top" | "bottom" | "left" | "right"; side?: "top" | "bottom" | "left" | "right";
} & (ChildrenBubbleProps | LinkBubbleProps); } & (ChildrenBubbleProps | LinkBubbleProps);
const InformationBubble = ({ const InformationBubble = ({
className, className,
icon = "circle-help", icon = "circle-help",
iconClassName, iconClassName,
side, side,
children, children,
href, href,
txt, txt,
target, target,
}: InformationBubbleProps) => { }: InformationBubbleProps) => {
return ( return (
<TooltipProvider delayDuration={0}> <TooltipProvider delayDuration={0}>
<HybridTooltip> <HybridTooltip>
<HybridTooltipTrigger <HybridTooltipTrigger
className={cn( className={cn(
`absolute -top-2 -right-2 flex size-6 items-center justify-center rounded-full bg-white`, `absolute -top-2 -right-2 flex size-6 items-center justify-center rounded-full bg-white`,
className, className,
)} )}
> >
<IconMatrix <IconMatrix
type={icon} type={icon}
className={cn("size-6 text-indigo-500", iconClassName)} className={cn("size-6 text-indigo-500", iconClassName)}
/> />
</HybridTooltipTrigger> </HybridTooltipTrigger>
<HybridTooltipContent <HybridTooltipContent
side={side} side={side}
className="border-border -mb-1.5 bg-white shadow-sm [&>span>svg]:invisible" className="border-border -mb-1.5 bg-white shadow-sm [&>span>svg]:invisible"
> >
{href ? ( {href ? (
<Link <Link
href={href} href={href}
target={target} target={target}
className="flex items-center justify-center text-sm text-blue-500 underline-offset-2 hover:underline" className="flex items-center justify-center text-sm text-blue-500 underline-offset-2 hover:underline"
> >
<ExternalLink className="mr-1 size-4" /> <ExternalLink className="mr-1 size-4" />
<p>{txt}</p> <p>{txt}</p>
</Link> </Link>
) : ( ) : (
<>{children}</> <>{children}</>
)} )}
</HybridTooltipContent> </HybridTooltipContent>
</HybridTooltip> </HybridTooltip>
</TooltipProvider> </TooltipProvider>
); );
}; };
export default InformationBubble; export default InformationBubble;

View file

@ -1,297 +1,297 @@
import { useContext, type ReactNode } from "react"; import {
import { BannerFactory } from "~/components/banners"; ExternalLink,
import { Footer, MiniFooter } from "~/components/footer"; Mail,
import { SiteHeader } from "~/components/navbar/site-header"; MessagesSquare,
import { useTranslation } from "~/providers/I18nProvider"; Paperclip,
import { UserViewContext, UserViewProvider } from "~/lib/userViewContext"; Search,
ShoppingBag,
Tickets,
UserCog,
} from "lucide-react";
import Head from "next/head"; import Head from "next/head";
import Link from "next/link"; import Link from "next/link";
import { import { usePathname } from "next/navigation";
ExternalLink, import { useRouter } from "next/router";
Mail, import { type ReactNode, useContext } from "react";
MessagesSquare, import toast from "react-hot-toast";
Paperclip, import { Sidebar } from "~/components/area-riservata/sidebar";
Search, import { BannerFactory } from "~/components/banners";
ShoppingBag, import { Footer, MiniFooter } from "~/components/footer";
Tickets, import { LoadingPage } from "~/components/loading";
UserCog, import { SiteHeader } from "~/components/navbar/site-header";
} from "lucide-react"; import { WhatsAppIcon } from "~/components/svgs";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator"; import { Separator } from "~/components/ui/separator";
import { usePathname } from "next/navigation"; import { UserViewContext, UserViewProvider } from "~/lib/userViewContext";
import { api } from "~/utils/api"; import { useTranslation } from "~/providers/I18nProvider";
import { Sidebar } from "~/components/area-riservata/sidebar";
import type { UsersId } from "~/schemas/public/Users";
import toast from "react-hot-toast";
import { useRouter } from "next/router";
import { import {
useSession, EnforcedSessionContext,
EnforcedSessionContext, useSession,
} from "~/providers/SessionProvider"; } from "~/providers/SessionProvider";
import { WhatsAppIcon } from "~/components/svgs"; import type { UsersId } from "~/schemas/public/Users";
import { LoadingPage } from "~/components/loading";
import type { ValidSession } from "~/server/api/trpc"; import type { ValidSession } from "~/server/api/trpc";
import { api } from "~/utils/api";
interface Props { interface Props {
children: ReactNode; children: ReactNode;
noFooter?: boolean; noFooter?: boolean;
} }
export const Layout = ({ children, noFooter }: Props) => { export const Layout = ({ children, noFooter }: Props) => {
const { data: bannerData } = api.settings.getBannerData.useQuery({ const { data: bannerData } = api.settings.getBannerData.useQuery({
area_riservata: false, area_riservata: false,
}); });
return ( return (
<div className="flex min-h-screen w-full flex-col"> <div className="flex min-h-screen w-full flex-col">
<SiteHeader /> <SiteHeader />
{bannerData?.map((banner) => ( {bannerData?.map((banner) => (
<div key={`banner-elem-${banner.idbanner}`}> <div key={`banner-elem-${banner.idbanner}`}>
{BannerFactory(banner)} {BannerFactory(banner)}
</div> </div>
))} ))}
<main className="h-full flex-1 grow">{children}</main> <main className="h-full flex-1 grow">{children}</main>
{!noFooter && <Footer />} {!noFooter && <Footer />}
{/* <CookieConsent /> */} {/* <CookieConsent /> */}
</div> </div>
); );
}; };
export const AreaRiservataLayout = ({ export const AreaRiservataLayout = ({
children, children,
noSidebar, noSidebar,
noFooter, noFooter,
ignoreSessionCheck, ignoreSessionCheck,
}: { }: {
children: ReactNode; children: ReactNode;
noSidebar?: boolean; noSidebar?: boolean;
noFooter?: boolean; noFooter?: boolean;
ignoreSessionCheck?: boolean; ignoreSessionCheck?: boolean;
}) => { }) => {
const { data: bannerData } = api.settings.getBannerData.useQuery({ const { data: bannerData } = api.settings.getBannerData.useQuery({
area_riservata: true, area_riservata: true,
}); });
const session = useSession(); const session = useSession();
if (!ignoreSessionCheck) { if (!ignoreSessionCheck) {
if (session.status === "pending") if (session.status === "pending")
return ( return (
<div className="flex h-full min-h-screen flex-col text-clip"> <div className="flex h-full min-h-screen flex-col text-clip">
<SiteHeader /> <SiteHeader />
<main className="flex h-full flex-1 overflow-auto"> <main className="flex h-full flex-1 overflow-auto">
<div className="flex h-auto w-full flex-col md:flex-row"> <div className="flex h-auto w-full flex-col md:flex-row">
<LoadingPage />; <LoadingPage />
</div> </div>
</main> </main>
{!noFooter && <MiniFooter />} {!noFooter && <MiniFooter />}
</div> </div>
); );
if (session.status !== "success" || !session.user) { if (session.status !== "success" || !session.user) {
window.location.reload(); window.location.reload();
return ( return (
<div className="flex h-full min-h-screen flex-col text-clip"> <div className="flex h-full min-h-screen flex-col text-clip">
<SiteHeader /> <SiteHeader />
<main className="flex h-full flex-1 overflow-auto"> <main className="flex h-full flex-1 overflow-auto">
<div className="flex h-auto w-full flex-col md:flex-row"> <div className="flex h-auto w-full flex-col md:flex-row">
<LoadingPage />; <LoadingPage />
</div> </div>
</main> </main>
{!noFooter && <MiniFooter />} {!noFooter && <MiniFooter />}
</div> </div>
); );
} }
} }
return ( return (
<div className="flex h-full min-h-screen flex-col text-clip"> <div className="flex h-full min-h-screen flex-col text-clip">
<SiteHeader /> <SiteHeader />
{bannerData?.map((banner) => ( {bannerData?.map((banner) => (
<div key={`banner-elem-${banner.idbanner}`}> <div key={`banner-elem-${banner.idbanner}`}>
{BannerFactory(banner)} {BannerFactory(banner)}
</div> </div>
))} ))}
<main className="flex h-full flex-1 overflow-auto"> <main className="flex h-full flex-1 overflow-auto">
<div className="flex h-auto w-full flex-col md:flex-row"> <div className="flex h-auto w-full flex-col md:flex-row">
{noSidebar ? null : ( {noSidebar ? null : (
<Sidebar isAdmin={session.user?.isAdmin || false} /> <Sidebar isAdmin={session.user?.isAdmin || false} />
)} )}
<EnforcedSessionContext.Provider value={session as ValidSession}> <EnforcedSessionContext.Provider value={session as ValidSession}>
{children} {children}
</EnforcedSessionContext.Provider> </EnforcedSessionContext.Provider>
</div> </div>
</main> </main>
{!noFooter && <MiniFooter />} {!noFooter && <MiniFooter />}
{/* <CookieConsent /> */} {/* <CookieConsent /> */}
</div> </div>
); );
}; };
export const AreaRiservataLayoutUserView = ({ export const AreaRiservataLayoutUserView = ({
children, children,
}: { }: {
children: ReactNode & { props: { userId: UsersId } }; children: ReactNode & { props: { userId: UsersId } };
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const props = children.props as { userId: UsersId }; const props = children.props as { userId: UsersId };
return ( return (
<div className="flex h-full min-h-screen flex-col bg-white"> <div className="flex h-full min-h-screen flex-col bg-white">
<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> <Head>
<title>{t.heads.area_riservata_titolo}</title> <title>{t.heads.area_riservata_titolo}</title>
<meta <meta
name="description" name="description"
content={t.heads.area_riservata_description} content={t.heads.area_riservata_description}
/> />
</Head> </Head>
<div className="flex h-auto w-full flex-col md:flex-row"> <div className="flex h-auto w-full flex-col md:flex-row">
<Sidebar isAdmin /> <Sidebar 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>
</UserViewProvider> </UserViewProvider>
</main> </main>
<MiniFooter /> <MiniFooter />
{/* <CookieConsent /> */} {/* <CookieConsent /> */}
</div> </div>
); );
}; };
const UserViewHeader = () => { const UserViewHeader = () => {
const data = useContext(UserViewContext); const data = useContext(UserViewContext);
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const { mutate: swap } = api.auth.swapUser.useMutation({ const { mutate: swap } = api.auth.swapUser.useMutation({
onSuccess: async () => { onSuccess: async () => {
toast.success("Utente cambiato con successo"); toast.success("Utente cambiato con successo");
await router.push("/area-riservata/dashboard"); await router.push("/area-riservata/dashboard");
router.reload(); router.reload();
}, },
}); });
if (!data) return <div>Errore</div>; if (!data) return <div>Errore</div>;
return ( return (
<div className="mb-5 flex flex-col space-y-5"> <div className="mb-5 flex flex-col space-y-5">
<div className="flex flex-row flex-wrap items-baseline gap-3"> <div className="flex flex-row flex-wrap items-baseline gap-3">
<div className="text-3xl">{data.username}</div> <div className="text-3xl">{data.username}</div>
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{data.email} | {data.telefono} {data.email} | {data.telefono}
</div> </div>
<Link href={`https://wa.me/${data.telefono}`} target="_blank"> <Link href={`https://wa.me/${data.telefono}`} target="_blank">
<Button <Button
className="flex items-center gap-2" className="flex items-center gap-2"
variant="success" variant="success"
size="sm" size="sm"
> >
<WhatsAppIcon className="size-4 fill-white" /> <WhatsAppIcon className="size-4 fill-white" />
WhatsApp WhatsApp
</Button> </Button>
</Link> </Link>
</div> </div>
<div className="flex flex-row flex-wrap gap-2"> <div className="flex flex-row flex-wrap gap-2">
<Link href={`/area-riservata/admin/user-view/edit-user/${data.id}`}> <Link href={`/area-riservata/admin/user-view/edit-user/${data.id}`}>
<Button <Button
className="flex items-center gap-2 border" className="flex items-center gap-2 border"
variant={pathname.includes("edit-user") ? "secondary" : "outline"} variant={pathname.includes("edit-user") ? "secondary" : "outline"}
size="sm" size="sm"
> >
<UserCog /> Profilo <UserCog /> Profilo
</Button> </Button>
</Link> </Link>
<Link href={`/area-riservata/admin/user-view/servizio/${data.id}`}> <Link href={`/area-riservata/admin/user-view/servizio/${data.id}`}>
<Button <Button
className="flex items-center gap-2 border" className="flex items-center gap-2 border"
variant={pathname.includes("servizio") ? "secondary" : "outline"} variant={pathname.includes("servizio") ? "secondary" : "outline"}
size="sm" size="sm"
> >
<Tickets /> Servizi <Tickets /> Servizi
</Button> </Button>
</Link> </Link>
<Link href={`/area-riservata/admin/user-view/ricerca/${data.id}`}> <Link href={`/area-riservata/admin/user-view/ricerca/${data.id}`}>
<Button <Button
className="flex items-center gap-2 border" className="flex items-center gap-2 border"
variant={pathname.includes("ricerca") ? "secondary" : "outline"} variant={pathname.includes("ricerca") ? "secondary" : "outline"}
size="sm" size="sm"
> >
<Search /> Ricerca <Search /> Ricerca
</Button> </Button>
</Link> </Link>
<Link href={`/area-riservata/admin/user-view/comunicazioni/${data.id}`}> <Link href={`/area-riservata/admin/user-view/comunicazioni/${data.id}`}>
<Button <Button
className="flex items-center gap-2 border" className="flex items-center gap-2 border"
variant={ variant={
pathname.includes("comunicazioni") ? "secondary" : "outline" pathname.includes("comunicazioni") ? "secondary" : "outline"
} }
size="sm" size="sm"
> >
<Mail /> Comunicazioni <Mail /> Comunicazioni
</Button> </Button>
</Link> </Link>
<Link href={`/area-riservata/admin/user-view/ordini/${data.id}`}> <Link href={`/area-riservata/admin/user-view/ordini/${data.id}`}>
<Button <Button
className="flex items-center gap-2 border" className="flex items-center gap-2 border"
variant={pathname.includes("ordini") ? "secondary" : "outline"} variant={pathname.includes("ordini") ? "secondary" : "outline"}
size="sm" size="sm"
> >
<ShoppingBag /> Ordini <ShoppingBag /> Ordini
</Button> </Button>
</Link> </Link>
<Link href={`/area-riservata/admin/user-view/allegati/${data.id}`}> <Link href={`/area-riservata/admin/user-view/allegati/${data.id}`}>
<Button <Button
className="flex items-center gap-2 border" className="flex items-center gap-2 border"
variant={pathname.includes("allegati") ? "secondary" : "outline"} variant={pathname.includes("allegati") ? "secondary" : "outline"}
size="sm" size="sm"
> >
<Paperclip /> Allegati <Paperclip /> Allegati
</Button> </Button>
</Link> </Link>
<Link <Link
href={ href={
data.chatid data.chatid
? { ? {
pathname: "/area-riservata/admin/chats", pathname: "/area-riservata/admin/chats",
query: { chatid: data.chatid }, query: { chatid: data.chatid },
} }
: "/area-riservata/admin/chats" : "/area-riservata/admin/chats"
} }
target="_blank" target="_blank"
> >
<Button <Button
className="flex items-center gap-2 border" className="flex items-center gap-2 border"
variant={!data.chatid ? "ghost" : "outline"} variant={!data.chatid ? "ghost" : "outline"}
size="sm" size="sm"
> >
<MessagesSquare /> Chat <MessagesSquare /> Chat
</Button> </Button>
</Link> </Link>
<Button <Button
size="sm" size="sm"
className="flex gap-2" className="flex gap-2"
onClick={() => onClick={() =>
swap({ swap({
id: data.id, id: data.id,
}) })
} }
> >
<ExternalLink /> Login <ExternalLink /> Login
</Button> </Button>
</div> </div>
<Separator /> <Separator />
</div> </div>
); );
}; };

View file

@ -1,65 +1,65 @@
"use client"; "use client";
import dynamic from "next/dynamic";
import { Button } from "~/components/ui/button";
import { MapIcon } from "lucide-react"; import { MapIcon } from "lucide-react";
import { useTranslation } from "~/providers/I18nProvider"; import dynamic from "next/dynamic";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { useState } from "react"; import { useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { useTranslation } from "~/providers/I18nProvider";
const Map = dynamic(() => import("~/components/map/Map"), { const MapComp = dynamic(() => import("~/components/map/Map"), {
ssr: false, ssr: false,
}); });
export const MappaDialogFullscreen = ({ export const MappaDialogFullscreen = ({
lat, lat,
long, long,
}: { }: {
lat: string | null; lat: string | null;
long: string | null; long: string | null;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button className="w-auto grow" disabled={!lat || !long}> <Button className="w-auto grow" disabled={!lat || !long}>
<MapIcon className="size-5" /> {t.mappa} <MapIcon className="size-5" /> {t.mappa}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="h-full w-screen max-w-[100vw] p-0 sm:max-h-[60vh] sm:max-w-2xl [&_#dialog-close]:bg-white [&_#dialog-close]:opacity-100 [&_#dialog-close>svg]:size-8"> <DialogContent className="h-full w-screen max-w-[100vw] p-0 sm:max-h-[60vh] sm:max-w-2xl [&_#dialog-close]:bg-white [&_#dialog-close]:opacity-100 [&_#dialog-close>svg]:size-8">
<DialogHeader className="hidden h-0"> <DialogHeader className="hidden h-0">
<DialogTitle className="sr-only">{t.mappa}</DialogTitle> <DialogTitle className="sr-only">{t.mappa}</DialogTitle>
<DialogDescription className="sr-only">{t.mappa}</DialogDescription> <DialogDescription className="sr-only">{t.mappa}</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="size-full"> <div className="size-full">
{lat && long && ( {lat && long && (
<> <>
<Map <MapComp
posix={{ posix={{
pos: { pos: {
lat: Number.parseFloat(lat), lat: Number.parseFloat(lat),
lng: Number.parseFloat(long), lng: Number.parseFloat(long),
}, },
popupTxt: popupTxt:
"Posizione esatta fornita dopo la richiesta di contatto", "Posizione esatta fornita dopo la richiesta di contatto",
}} }}
markerType="home" markerType="home"
scrollWheelZoom scrollWheelZoom
width="100%" width="100%"
height="100%" height="100%"
zoom={14} zoom={14}
/> />
</> </>
)} )}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

View file

@ -1,15 +1,15 @@
import { forwardRef, type InputHTMLAttributes } from "react"; import { forwardRef, type InputHTMLAttributes } from "react";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "~/components/ui/accordion"; } from "~/components/ui/accordion";
import type { Faq } from "~/i18n/locales"; import type { Faq } from "~/i18n/locales";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface AccordionCompProps extends InputHTMLAttributes<HTMLDivElement> { interface AccordionCompProps extends InputHTMLAttributes<HTMLDivElement> {
texts: Faq[]; texts: Faq[];
} }
/** /**
@ -29,30 +29,30 @@ interface AccordionCompProps extends InputHTMLAttributes<HTMLDivElement> {
* ``` * ```
*/ */
export const AccordionComp = forwardRef<HTMLDivElement, AccordionCompProps>( export const AccordionComp = forwardRef<HTMLDivElement, AccordionCompProps>(
({ texts, className }, ref) => { ({ texts, className }, ref) => {
if (!texts || texts.length === 0) { if (!texts || texts.length === 0) {
return null; // Return null if no texts are provided return null; // Return null if no texts are provided
} }
return ( return (
<div <div
className={cn("mx-auto flex max-w-xl justify-center", className)} className={cn("mx-auto flex max-w-xl justify-center", className)}
ref={ref} ref={ref}
> >
<Accordion type="single" collapsible className="w-full"> <Accordion type="single" collapsible className="w-full">
{texts.map((text, index) => ( {texts.map((text, index) => (
<AccordionItem key={`ac-item-${index}`} value={`item-${index}`}> <AccordionItem key={`ac-item-${index}`} value={`item-${index}`}>
<AccordionTrigger className="cursor-pointer gap-4 text-left text-lg"> <AccordionTrigger className="cursor-pointer gap-4 text-left text-lg">
{text.title} {text.title}
</AccordionTrigger> </AccordionTrigger>
<AccordionContent className="text-base"> <AccordionContent className="text-base">
{text.description} {text.description}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
))} ))}
</Accordion> </Accordion>
</div> </div>
); );
}, },
); );
AccordionComp.displayName = "AccordionComp"; AccordionComp.displayName = "AccordionComp";

View file

@ -1,242 +1,241 @@
"use client"; "use client";
import { api } from "~/utils/api";
import { useState, type FormEvent } from "react";
import { import {
Elements, Elements,
PaymentElement, PaymentElement,
useElements, useElements,
useStripe, useStripe,
} from "@stripe/react-stripe-js"; } from "@stripe/react-stripe-js";
import type { Stripe } from "@stripe/stripe-js";
import { ArrowRight, ExternalLink } from "lucide-react";
import Link from "next/link";
import { type FormEvent, useState } from "react";
import LoadingButton from "~/components/custom_ui/loading-button";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page"; import { Status500 } from "~/components/status-page";
import LoadingButton from "~/components/custom_ui/loading-button";
import { useTranslation } from "~/providers/I18nProvider";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import type { Stripe } from "@stripe/stripe-js";
import { formatCurrency } from "~/lib/utils";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { env } from "~/env.mjs";
import { formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { PaymentsId } from "~/schemas/public/Payments"; import type { PaymentsId } from "~/schemas/public/Payments";
import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario"; import type { PrezziarioIdprezziario } from "~/schemas/public/Prezziario";
import { ArrowRight, ExternalLink } from "lucide-react"; import { api } from "~/utils/api";
import { env } from "~/env.mjs";
import Link from "next/link";
type AcquistoProcessingProps = { type AcquistoProcessingProps = {
paymentId: PaymentsId; paymentId: PaymentsId;
packId: PrezziarioIdprezziario; packId: PrezziarioIdprezziario;
stripePromise: Promise<Stripe | null>; stripePromise: Promise<Stripe | null>;
}; };
export const AcquistoProcessing = ({ export const AcquistoProcessing = ({
paymentId, paymentId,
packId, packId,
stripePromise, stripePromise,
}: AcquistoProcessingProps) => { }: AcquistoProcessingProps) => {
const [stripeIntent, setStripeIntent] = useState<{ const [stripeIntent, setStripeIntent] = useState<{
clientSecret: string | null; clientSecret: string | null;
titolo: string; titolo: string;
prezzo: number; prezzo: number;
descrizione: string; descrizione: string;
} | null>(null); } | null>(null);
const { t } = useTranslation(); const { t } = useTranslation();
const [generatedIntent, setGeneratedIntent] = useState(false); const [generatedIntent, setGeneratedIntent] = useState(false);
const { mutate, isPending } = api.stripe.createIntent.useMutation({ const { mutate, isPending } = api.stripe.createIntent.useMutation({
onSuccess: (data) => { onSuccess: (data) => {
setStripeIntent(data); setStripeIntent(data);
}, },
}); });
if (generatedIntent && !isPending && !stripeIntent) { if (generatedIntent && !isPending && !stripeIntent) {
return <Status500 />; return <Status500 />;
} }
return ( return (
<div className="mx-auto my-4 w-full max-w-3xl space-y-8 rounded-md"> <div className="mx-auto my-4 w-full max-w-3xl space-y-8 rounded-md">
<h3 className="mt-2 text-center text-2xl font-bold tracking-wide text-neutral-700 uppercase"> <h3 className="mt-2 text-center text-2xl font-bold tracking-wide text-neutral-700 uppercase">
{t.acquisto.titolo} {t.acquisto.titolo}
</h3> </h3>
{!generatedIntent ? ( {!generatedIntent ? (
<PricePreparation <PricePreparation
packId={packId} packId={packId}
onProcedi={() => { onProcedi={() => {
setGeneratedIntent(true); setGeneratedIntent(true);
mutate({ mutate({
paymentId, paymentId,
}); });
}} }}
/> />
) : isPending ? ( ) : isPending ? (
<LoadingPage /> <LoadingPage />
) : ( ) : (
stripeIntent && stripeIntent?.clientSecret && (
stripeIntent.clientSecret && ( <>
<> <Elements
<Elements options={{
options={{ appearance: {
appearance: { theme: "stripe",
theme: "stripe", variables: {
variables: { colorPrimary: "#18181b",
colorPrimary: "#18181b", colorText: "#18181b",
colorText: "#18181b", },
}, },
}, clientSecret: stripeIntent.clientSecret,
clientSecret: stripeIntent.clientSecret, }}
}} stripe={stripePromise}
stripe={stripePromise} >
> <CheckoutForm
<CheckoutForm titolo={stripeIntent.titolo}
titolo={stripeIntent.titolo} prezzo={stripeIntent.prezzo}
prezzo={stripeIntent.prezzo} descrizione={stripeIntent.descrizione}
descrizione={stripeIntent.descrizione} />
/> </Elements>
</Elements> </>
</> )
) )}
)} </div>
</div> );
);
}; };
export const PricePreparation = ({ export const PricePreparation = ({
packId, packId,
onProcedi, onProcedi,
}: { }: {
packId: PrezziarioIdprezziario; packId: PrezziarioIdprezziario;
onProcedi: () => void; onProcedi: () => void;
}) => { }) => {
const { data: prezziario, isLoading } = const { data: prezziario, isLoading } =
api.prezziario.getPrezzoPerServizio.useQuery({ api.prezziario.getPrezzoPerServizio.useQuery({
idprezziario: packId, idprezziario: packId,
}); });
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="mx-auto mt-4 max-w-2xl p-2 sm:mt-10"> <div className="mx-auto mt-4 max-w-2xl p-2 sm:mt-10">
{isLoading || !prezziario ? ( {isLoading || !prezziario ? (
<> <>
<h3 className="text-2xl font-semibold">{t.acquisto.preparazione}</h3> <h3 className="text-2xl font-semibold">{t.acquisto.preparazione}</h3>
<LoadingPage /> <LoadingPage />
</> </>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
<h3 className="text-center text-2xl font-semibold"> <h3 className="text-center text-2xl font-semibold">
{t.acquisto.servizi_acquisto} {t.acquisto.servizi_acquisto}
</h3> </h3>
<div className="space-y-6"> <div className="space-y-6">
<p className="text-3xl">{prezziario.nome_it}</p> <p className="text-3xl">{prezziario.nome_it}</p>
<p className="text-lg"> <p className="text-lg">
Cod: &ldquo;{prezziario.idprezziario}&rdquo; Cod: &ldquo;{prezziario.idprezziario}&rdquo;
</p> </p>
<p className="text-4xl font-bold"> <p className="text-4xl font-bold">
{formatCurrency(prezziario.prezzo_cent / 100)} {formatCurrency(prezziario.prezzo_cent / 100)}
</p> </p>
<p>{prezziario.desc_it}</p> <p>{prezziario.desc_it}</p>
</div> </div>
{prezziario.testo_condizioni && ( {prezziario.testo_condizioni && (
<Link <Link
aria-label="Leggi le condizioni" aria-label="Leggi le condizioni"
href={`/servizio/condizioni/${prezziario.testo_condizioni}`} href={`/servizio/condizioni/${prezziario.testo_condizioni}`}
target="_blank" target="_blank"
className="block w-fit" className="block w-fit"
> >
<Button className="flex w-full items-center gap-2 text-sm"> <Button className="flex w-full items-center gap-2 text-sm">
<span>{t.acquisto.leggi_condizioni}</span> <span>{t.acquisto.leggi_condizioni}</span>
<ExternalLink /> <ExternalLink />
</Button> </Button>
</Link> </Link>
)} )}
<Button <Button
onClick={onProcedi} onClick={onProcedi}
className="flex w-full items-center gap-2 text-lg" className="flex w-full items-center gap-2 text-lg"
variant="info" variant="info"
size="xl" size="xl"
disabled={isLoading || !prezziario} disabled={isLoading || !prezziario}
> >
<span>{t.acquisto.procedi_pagamento}</span> <ArrowRight /> <span>{t.acquisto.procedi_pagamento}</span> <ArrowRight />
</Button> </Button>
</div> </div>
)} )}
</div> </div>
); );
}; };
const CheckoutForm = ({ const CheckoutForm = ({
titolo, titolo,
prezzo, prezzo,
descrizione, descrizione,
}: { }: {
titolo: string; titolo: string;
prezzo: number; prezzo: number;
descrizione: string; descrizione: string;
}) => { }) => {
const stripe = useStripe(); const stripe = useStripe();
const elements = useElements(); const elements = useElements();
const [message, setMessage] = useState<string | null>(null); const [message, setMessage] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const { t } = useTranslation(); const { t } = useTranslation();
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => { const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
console.log("submitting payment"); console.log("submitting payment");
if (!stripe || !elements) { if (!stripe || !elements) {
return; return;
} }
setIsLoading(true); setIsLoading(true);
const { error } = await stripe.confirmPayment({ const { error } = await stripe.confirmPayment({
elements, elements,
confirmParams: { confirmParams: {
return_url: `${env.NEXT_PUBLIC_BASE_URL}/servizio/acquisto-processing`, return_url: `${env.NEXT_PUBLIC_BASE_URL}/servizio/acquisto-processing`,
}, },
}); });
if (error.type === "card_error" || error.type === "validation_error") { if (error.type === "card_error" || error.type === "validation_error") {
setMessage(error.message || "An unexpected error occurred."); setMessage(error.message || "An unexpected error occurred.");
} else { } else {
setMessage("An unexpected error occurred."); setMessage("An unexpected error occurred.");
} }
setIsLoading(false); setIsLoading(false);
}; };
return ( return (
<Card className="flex w-full flex-col justify-between text-left"> <Card className="flex w-full flex-col justify-between text-left">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
<p className="text-2xl">{titolo}</p> <p className="text-2xl">{titolo}</p>
</CardTitle> </CardTitle>
<p>{descrizione}</p> <p>{descrizione}</p>
<span> <span>
<span className="text-3xl font-bold"> <span className="text-3xl font-bold">
{formatCurrency(prezzo / 100)} {formatCurrency(prezzo / 100)}
</span>{" "} </span>{" "}
<span className="text-base font-normal sm:text-sm"> <span className="text-base font-normal sm:text-sm">
{t.pricing_cmp.risultati_iva} {t.pricing_cmp.risultati_iva}
</span> </span>
</span> </span>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form id="payment-form" onSubmit={handleSubmit} className="space-y-5"> <form id="payment-form" onSubmit={handleSubmit} className="space-y-5">
<PaymentElement <PaymentElement
id="payment-element" id="payment-element"
options={{ layout: "accordion" }} options={{ layout: "accordion" }}
/> />
<LoadingButton <LoadingButton
aria-label="Procedi al pagamento" aria-label="Procedi al pagamento"
type="submit" type="submit"
className="w-full" className="w-full"
disabled={isLoading || !stripe || !elements} disabled={isLoading || !stripe || !elements}
loading={isLoading || !stripe || !elements} loading={isLoading || !stripe || !elements}
> >
{t.procedi} {t.procedi}
</LoadingButton> </LoadingButton>
{message && <div id="payment-message">{message}</div>} {message && <div id="payment-message">{message}</div>}
</form> </form>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };

View file

@ -1,187 +1,187 @@
"use client"; "use client";
import { format } from "date-fns";
import { it } from "date-fns/locale";
import Image from "next/image";
import { useRef } from "react";
import { useReactToPrint } from "react-to-print";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card"; import { Card } from "~/components/ui/card";
import Image from "next/image";
import { format } from "date-fns";
import { useReactToPrint } from "react-to-print";
import { it } from "date-fns/locale";
import { formatCurrency } from "~/lib/utils";
import { useRef } from "react";
import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe"; import { PaymentMethodToString, type PaymentType } from "~/i18n/stripe";
import { formatCurrency } from "~/lib/utils";
export function ReceiptGenerator({ data }: { data: PurchaseData }) { export function ReceiptGenerator({ data }: { data: PurchaseData }) {
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const reactToPrintFn = useReactToPrint({ const reactToPrintFn = useReactToPrint({
contentRef, contentRef,
//print: async (target) => {console.log("Printing...", target.contentDocument);}, //print: async (target) => {console.log("Printing...", target.contentDocument);},
}); });
return ( return (
<div className="mx-auto max-w-4xl"> <div className="mx-auto max-w-4xl">
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Ricevuta di cortesia</h1> <h1 className="text-2xl font-bold">Ricevuta di cortesia</h1>
<Button onClick={() => reactToPrintFn()} className="print:hidden"> <Button onClick={() => reactToPrintFn()} className="print:hidden">
Stampa Stampa
</Button> </Button>
</div> </div>
<div ref={contentRef}> <div ref={contentRef}>
<Receipt data={data} /> <Receipt data={data} />
</div> </div>
</div> </div>
</div> </div>
); );
} }
interface Item { interface Item {
description: string; description: string;
price: number; price: number;
discount: number; discount: number;
} }
export interface PurchaseData { export interface PurchaseData {
paymentId: string; paymentId: string;
date: Date; date: Date;
clienteNome: string; clienteNome: string;
clienteIndirizzo: string; clienteIndirizzo: string;
paymentMethod: string; paymentMethod: string;
items: Item[]; items: Item[];
} }
interface ReceiptProps { interface ReceiptProps {
data: PurchaseData; data: PurchaseData;
} }
function Receipt({ data }: ReceiptProps) { function Receipt({ data }: ReceiptProps) {
const companyName = "Arcenia S.r.l."; const companyName = "Arcenia S.r.l.";
const companyAddress = const companyAddress =
"Via Beata Giovanna 1\nBassano del Grappa (VI) 36061\nItalia"; "Via Beata Giovanna 1\nBassano del Grappa (VI) 36061\nItalia";
const total = data.items.reduce( const total = data.items.reduce(
(acc, item) => acc + item.price * (1 - item.discount), (acc, item) => acc + item.price * (1 - item.discount),
0, 0,
); );
const note = const note =
"La presente è non costituisce ricevuta o fattura fiscale.\nRiceverai la fattura elettronica direttamente al tuo indirizzo di posta."; "La presente è non costituisce ricevuta o fattura fiscale.\nRiceverai la fattura elettronica direttamente al tuo indirizzo di posta.";
const footer = const footer =
"Arcenia Srl. | Tel. +39 0424529869 | Email: arca@infoalloggi.it"; "Arcenia Srl. | Tel. +39 0424529869 | Email: arca@infoalloggi.it";
return ( return (
<Card className="p-4 print:border-none print:shadow-none"> <Card className="p-4 print:border-none print:shadow-none">
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h1 className="text-2xl font-bold">{companyName}</h1> <h1 className="text-2xl font-bold">{companyName}</h1>
<p className="text-sm whitespace-pre-line">{companyAddress}</p> <p className="text-sm whitespace-pre-line">{companyAddress}</p>
</div> </div>
<div className="relative size-24"> <div className="relative size-24">
<Image <Image
src={"/favicon/favicon.png"} src={"/favicon/favicon.png"}
alt={`${companyName} logo`} alt={`${companyName} logo`}
fill fill
className="object-contain" className="object-contain"
/> />
</div> </div>
</div> </div>
<div className="border-b pb-4"> <div className="border-b pb-4">
<h2 className="text-center text-xl font-semibold"> <h2 className="text-center text-xl font-semibold">
Ricevuta di cortesia Ricevuta di cortesia
</h2> </h2>
<div className="mt-2 flex justify-between"> <div className="mt-2 flex justify-between">
<div> <div>
<p className="text-muted-foreground text-sm">ID Pagamento</p> <p className="text-muted-foreground text-sm">ID Pagamento</p>
<p className="text-sm">{data.paymentId}</p> <p className="text-sm">{data.paymentId}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-muted-foreground text-sm">Data</p> <p className="text-muted-foreground text-sm">Data</p>
<p className="font-medium"> <p className="font-medium">
{format(data.date, "PPP", { {format(data.date, "PPP", {
locale: it, locale: it,
})} })}
</p> </p>
<p className="text-sm"> <p className="text-sm">
{format(data.date, "p", { {format(data.date, "p", {
locale: it, locale: it,
})} })}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-2 gap-6">
<div> <div>
<h3 className="mb-1 font-semibold">Intestatario:</h3> <h3 className="mb-1 font-semibold">Intestatario:</h3>
<p className="font-medium">{data.clienteNome}</p> <p className="font-medium">{data.clienteNome}</p>
<p className="text-sm whitespace-pre-line"> <p className="text-sm whitespace-pre-line">
{data.clienteIndirizzo} {data.clienteIndirizzo}
</p> </p>
</div> </div>
</div> </div>
<div> <div>
<table className="w-full"> <table className="w-full">
<thead> <thead>
<tr className="border-b"> <tr className="border-b">
<th className="py-2 text-left">Descrizione</th> <th className="py-2 text-left">Descrizione</th>
<th className="py-2 text-right">Prezzo</th> <th className="py-2 text-right">Prezzo</th>
<th className="py-2 text-right">Sconto</th> <th className="py-2 text-right">Sconto</th>
<th className="py-2 text-right">Importo</th> <th className="py-2 text-right">Importo</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.items.map((item, idx) => ( {data.items.map((item, idx) => (
<tr key={idx} className="border-b"> <tr key={idx} className="border-b">
<td className="py-2">{item.description}</td> <td className="py-2">{item.description}</td>
<td className="py-2 text-right"> <td className="py-2 text-right">
{formatCurrency(item.price)} {formatCurrency(item.price)}
</td> </td>
<td className="py-2 text-right"> <td className="py-2 text-right">
{item.discount > 0 {item.discount > 0
? `-${(item.discount * 100).toFixed(0)}%` ? `-${(item.discount * 100).toFixed(0)}%`
: ""} : ""}
</td> </td>
<td className="py-2 text-right"> <td className="py-2 text-right">
{formatCurrency(item.price * (1 - item.discount))} {formatCurrency(item.price * (1 - item.discount))}
</td> </td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<div className="w-64"> <div className="w-64">
<div className="mt-1 flex justify-between pt-2 font-bold"> <div className="mt-1 flex justify-between pt-2 font-bold">
<span>Totale:</span> <span>Totale:</span>
<span>{formatCurrency(total)}</span> <span>{formatCurrency(total)}</span>
</div> </div>
<div className="flex justify-between py-1"> <div className="flex justify-between py-1">
<span>di cui IVA (22%):</span> <span>di cui IVA (22%):</span>
<span>{formatCurrency(total * 0.22)}</span> <span>{formatCurrency(total * 0.22)}</span>
</div> </div>
</div> </div>
</div> </div>
<div className="border-t pt-4"> <div className="border-t pt-4">
<div className="flex justify-between"> <div className="flex justify-between">
<div> <div>
<h3 className="mb-1 font-semibold">Metodo di pagamento</h3> <h3 className="mb-1 font-semibold">Metodo di pagamento</h3>
<p>{PaymentMethodToString(data.paymentMethod as PaymentType)}</p> <p>{PaymentMethodToString(data.paymentMethod as PaymentType)}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<h3 className="mb-1 font-semibold">Status pagamento</h3> <h3 className="mb-1 font-semibold">Status pagamento</h3>
<p className="font-medium text-green-600">Pagato</p> <p className="font-medium text-green-600">Pagato</p>
</div> </div>
</div> </div>
</div> </div>
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="mb-1 font-semibold">Note</h3> <h3 className="mb-1 font-semibold">Note</h3>
<p className="text-sm whitespace-pre-line">{note}</p> <p className="text-sm whitespace-pre-line">{note}</p>
</div> </div>
<div className="text-muted-foreground pt-6 text-center text-sm"> <div className="text-muted-foreground pt-6 text-center text-sm">
<p>{footer}</p> <p>{footer}</p>
</div> </div>
</div> </div>
</Card> </Card>
); );
} }

View file

@ -1,56 +1,57 @@
import { useEffect, useRef, useState } from "react";
import { LoadingPage } from "~/components/loading";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { TempTokensToken } from "~/schemas/public/TempTokens"; import type { TempTokensToken } from "~/schemas/public/TempTokens";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { LoadingPage } from "~/components/loading";
import { useEffect, useRef, useState } from "react";
export const AllegatoIframe = ({ export const AllegatoIframe = ({
className, className,
allegato, allegato,
}: { }: {
className?: string; className?: string;
allegato: string; allegato: string;
}) => { }) => {
const if_ref = useRef<HTMLIFrameElement>(null); const if_ref = useRef<HTMLIFrameElement>(null);
const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation(); const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation();
const [token, setToken] = useState<TempTokensToken | null>(null); const [token, setToken] = useState<TempTokensToken | null>(null);
const handleIframeLoad = () => { const handleIframeLoad = () => {
if (if_ref.current && if_ref.current.contentWindow) { if (if_ref.current?.contentWindow) {
const img = if_ref.current.contentWindow.document.querySelector("img"); const img = if_ref.current.contentWindow.document.querySelector("img");
if (img) { if (img) {
img.style.width = "100%"; img.style.width = "100%";
img.style.height = "100%"; img.style.height = "100%";
img.style.objectFit = "contain"; img.style.objectFit = "contain";
} }
} }
}; };
useEffect(() => { useEffect(() => {
async function set() { async function set() {
const token = await getToken(); const token = await getToken();
setToken(token); setToken(token);
} }
set().catch((e) => console.error(e)); set().catch((e) => console.error(e));
}, []); }, []);
if (!token) return <LoadingPage />; if (!token) return <LoadingPage />;
const requestUrl = `/go-api/storage/get/${allegato}?token=${String(token)}`; const requestUrl = `/go-api/storage/get/${allegato}?token=${String(token)}`;
return ( return (
<div <div
className={cn( className={cn(
"flex h-full w-full items-center justify-center object-contain", "flex h-full w-full items-center justify-center object-contain",
className, className,
)} )}
> >
<iframe {/** biome-ignore lint/a11y/noNoninteractiveElementInteractions: <need interaction> */}
title="Allegato" <iframe
ref={if_ref} title="Allegato"
src={requestUrl} ref={if_ref}
className="h-full w-full" src={requestUrl}
onLoad={handleIframeLoad} className="h-full w-full"
allowFullScreen onLoad={handleIframeLoad}
/> allowFullScreen
</div> />
); </div>
);
}; };

View file

@ -1,34 +1,34 @@
import { getTitoloTranslation } from "~/lib/annuncio_details";
import { CardAnnuncio } from "~/components/annuncio_card"; import { CardAnnuncio } from "~/components/annuncio_card";
import { getTitoloTranslation } from "~/lib/annuncio_details";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import type { AnnuncioRicerca } from "~/server/controllers/annunci.controller"; import type { AnnuncioRicerca } from "~/server/controllers/annunci.controller";
export const AnnunciGrid = ({ pagedata }: { pagedata: AnnuncioRicerca[] }) => { export const AnnunciGrid = ({ pagedata }: { pagedata: AnnuncioRicerca[] }) => {
const { locale } = useTranslation(); const { locale } = useTranslation();
return ( return (
<div className="relative z-0 mx-auto grid max-w-7xl grid-flow-row grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="relative z-0 mx-auto grid max-w-7xl grid-flow-row grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{pagedata.map((annuncio) => ( {pagedata.map((annuncio) => (
<CardAnnuncio <CardAnnuncio
id={annuncio.id} id={annuncio.id}
key={annuncio.codice} key={annuncio.codice}
codice={annuncio.codice} codice={annuncio.codice}
prezzo={annuncio.prezzo} prezzo={annuncio.prezzo}
titolo={getTitoloTranslation({ titolo={getTitoloTranslation({
locale: locale, locale: locale,
titolo_it: annuncio.titolo_it, titolo_it: annuncio.titolo_it,
titolo_en: annuncio.titolo_en, titolo_en: annuncio.titolo_en,
})} })}
mq={annuncio.mq} mq={annuncio.mq}
comune={annuncio.comune} comune={annuncio.comune}
provincia={annuncio.provincia} provincia={annuncio.provincia}
consegna={annuncio.consegna} consegna={annuncio.consegna}
camere={annuncio.numero_camere} camere={annuncio.numero_camere}
immagini={annuncio.url_immagini || undefined} immagini={annuncio.url_immagini || undefined}
tipo={annuncio.tipo} tipo={annuncio.tipo}
stato={annuncio.stato} stato={annuncio.stato}
/> />
))} ))}
</div> </div>
); );
}; };

View file

@ -1,161 +1,161 @@
import { AnimatePresence, motion } from "framer-motion";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { AnnuncioRicercaWPosition } from "~/server/controllers/annunci.controller"; import Image from "next/image";
import { api } from "~/utils/api"; import {
type Dispatch,
Fragment,
memo,
type SetStateAction,
useState,
} from "react";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page"; import { Status500 } from "~/components/status-page";
import { useTranslation } from "~/providers/I18nProvider";
import { AnimatePresence, motion } from "framer-motion";
import Image from "next/image";
import { formatCurrency } from "~/lib/utils";
import { handleConsegna } from "~/lib/annuncio_details"; import { handleConsegna } from "~/lib/annuncio_details";
import { import { formatCurrency } from "~/lib/utils";
Fragment, import { useTranslation } from "~/providers/I18nProvider";
memo,
useState,
type Dispatch,
type SetStateAction,
} from "react";
import { useRicerca } from "~/providers/RicercaProvider"; import { useRicerca } from "~/providers/RicercaProvider";
import type { AnnuncioRicercaWPosition } from "~/server/controllers/annunci.controller";
import { api } from "~/utils/api";
const Map = dynamic(() => import("~/components/map/Map"), { const MapComp = dynamic(() => import("~/components/map/Map"), {
ssr: false, ssr: false,
}); });
export const MapSection = () => { export const MapSection = () => {
const { comune, consegna, tipo } = useRicerca(); const { comune, consegna, tipo } = useRicerca();
const [selected, setSelected] = useState<AnnuncioRicercaWPosition | null>( const [selected, setSelected] = useState<AnnuncioRicercaWPosition | null>(
null, null,
); );
return ( return (
<div className="relative z-50 flex h-[60vh] w-full items-center justify-center sm:h-[65vh]"> <div className="relative z-50 flex h-[60vh] w-full items-center justify-center sm:h-[65vh]">
<MapDisplay <MapDisplay
selectTipo={tipo} selectTipo={tipo}
selectComune={comune} selectComune={comune}
selectConsegna={consegna} selectConsegna={consegna}
setSelected={setSelected} setSelected={setSelected}
/> />
<SelectedComp selected={selected} /> <SelectedComp selected={selected} />
</div> </div>
); );
}; };
const MapDisplay = memo( const MapDisplay = memo(
({ ({
selectTipo, selectTipo,
selectComune, selectComune,
selectConsegna, selectConsegna,
setSelected, setSelected,
}: { }: {
selectTipo: string; selectTipo: string;
selectComune: string; selectComune: string;
selectConsegna: number | null; selectConsegna: number | null;
setSelected: Dispatch<SetStateAction<AnnuncioRicercaWPosition | null>>; setSelected: Dispatch<SetStateAction<AnnuncioRicercaWPosition | null>>;
}) => { }) => {
const { data, isLoading } = api.annunci.getMapping.useQuery({ const { data, isLoading } = api.annunci.getMapping.useQuery({
tipologia: selectTipo, tipologia: selectTipo,
comune: selectComune, comune: selectComune,
consegna: selectConsegna || undefined, consegna: selectConsegna || undefined,
}); });
if (isLoading) return <LoadingPage />; if (isLoading) return <LoadingPage />;
if (!data) return <Status500 />; if (!data) return <Status500 />;
const posizioni = data const posizioni = data
.map((a) => { .map((a) => {
if (a.lat_secondario && a.lon_secondario) { if (a.lat_secondario && a.lon_secondario) {
return { return {
pos: { pos: {
lat: parseFloat(a.lat_secondario), lat: parseFloat(a.lat_secondario),
lng: parseFloat(a.lon_secondario), lng: parseFloat(a.lon_secondario),
}, },
onClick: () => setSelected(a), onClick: () => setSelected(a),
markerTxt: `${a.prezzo / 1e2}`, markerTxt: `${a.prezzo / 1e2}`,
}; };
} }
return undefined; return undefined;
}) })
.filter((a) => a !== undefined); .filter((a) => a !== undefined);
return ( return (
<Map <MapComp
isGroup isGroup
groupData={posizioni} groupData={posizioni}
posix={{ pos: { lat: 45.764718, lng: 11.73067 } }} posix={{ pos: { lat: 45.764718, lng: 11.73067 } }}
width="100%" width="100%"
height="100%" height="100%"
zoom={13} zoom={13}
scrollWheelZoom scrollWheelZoom
markerType="price" markerType="price"
onMapClick={() => setSelected(null)} onMapClick={() => setSelected(null)}
/> />
); );
}, },
); );
MapDisplay.displayName = "MapDisplay"; MapDisplay.displayName = "MapDisplay";
const SelectedComp = memo( const SelectedComp = memo(
({ selected }: { selected: AnnuncioRicercaWPosition | null }) => { ({ selected }: { selected: AnnuncioRicercaWPosition | null }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Fragment> <Fragment>
<AnimatePresence initial={false}> <AnimatePresence initial={false}>
{selected && ( {selected && (
<motion.a <motion.a
aria-label="Visualizza Dettagli Annuncio" aria-label="Visualizza Dettagli Annuncio"
href={`/annuncio/${selected.codice}`} href={`/annuncio/${selected.codice}`}
target="_blank" target="_blank"
className="group absolute right-0 bottom-0 left-0 z-20 mx-2 mb-2 flex flex-col gap-2 rounded-md bg-white p-4 ring-neutral-500 hover:ring-1" className="group absolute right-0 bottom-0 left-0 z-20 mx-2 mb-2 flex flex-col gap-2 rounded-md bg-white p-4 ring-neutral-500 hover:ring-1"
initial={{ opacity: 0, scale: 0 }} initial={{ opacity: 0, scale: 0 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0 }} exit={{ opacity: 0, scale: 0 }}
transition={{ transition={{
duration: 0.1, duration: 0.1,
scale: { type: "spring", visualDuration: 0.1, bounce: 0.2 }, scale: { type: "spring", visualDuration: 0.1, bounce: 0.2 },
}} }}
key={selected.id} key={selected.id}
> >
<div className="flex gap-2"> <div className="flex gap-2">
{selected.url_immagini && selected.url_immagini[0] ? ( {selected.url_immagini?.[0] ? (
<Image <Image
src={selected.url_immagini[0]} src={selected.url_immagini[0]}
width={500} width={500}
height={500} height={500}
className="size-24 rounded-md object-cover sm:size-40" className="size-24 rounded-md object-cover sm:size-40"
alt="a" alt="a"
/> />
) : ( ) : (
<div className="size-20 bg-gray-200 sm:size-40" /> <div className="size-20 bg-gray-200 sm:size-40" />
)} )}
<div className="flex flex-col sm:gap-2 sm:text-lg"> <div className="flex flex-col sm:gap-2 sm:text-lg">
<h6 className="font-semibold underline-offset-2 group-hover:underline"> <h6 className="font-semibold underline-offset-2 group-hover:underline">
{selected.codice} {selected.codice}
</h6> </h6>
<h3 className="text-sm sm:text-base">{selected.titolo_it}</h3> <h3 className="text-sm sm:text-base">{selected.titolo_it}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
<h3 className="font-semibold"> <h3 className="font-semibold">
{formatCurrency(selected.prezzo / 1e2)} {formatCurrency(selected.prezzo / 1e2)}
</h3> </h3>
<h3>{selected.mq}mq</h3> <h3>{selected.mq}mq</h3>
</div> </div>
<p> <p>
{handleConsegna({ {handleConsegna({
consegna: selected.consegna, consegna: selected.consegna,
consegna_da: t.card.consegna_da, consegna_da: t.card.consegna_da,
mesi: t.preferenze.mesi, mesi: t.preferenze.mesi,
subito: t.card.consegna_subito, subito: t.card.consegna_subito,
aggiornamento: t.card.in_aggiornamento, aggiornamento: t.card.in_aggiornamento,
})} })}
</p> </p>
</div> </div>
</div> </div>
</motion.a> </motion.a>
)} )}
</AnimatePresence> </AnimatePresence>
</Fragment> </Fragment>
); );
}, },
); );
SelectedComp.displayName = "SelectedComp"; SelectedComp.displayName = "SelectedComp";

View file

@ -1,118 +1,117 @@
"use client"; "use client";
import { DialogTrigger } from "@radix-ui/react-dialog";
import { getCookie, setCookie } from "cookies-next/client"; import { getCookie, setCookie } from "cookies-next/client";
import { add } from "date-fns"; import { add } from "date-fns";
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { useRouter } from "next/router";
import { ArrowRight, CircleHelp } from "lucide-react"; import { ArrowRight, CircleHelp } from "lucide-react";
import { DialogTrigger } from "@radix-ui/react-dialog"; import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
const COOKIE_KEY = "tipologia_tutorial_shown"; const COOKIE_KEY = "tipologia_tutorial_shown";
export const CTA_TipologiaModal = () => { export const CTA_TipologiaModal = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const path = useRouter().asPath; const path = useRouter().asPath;
const hasCookie = getCookie(COOKIE_KEY) === "true"; const hasCookie = getCookie(COOKIE_KEY) === "true";
const update = async (v: "Transitorio" | "Stabile" | null) => { const update = async (v: "Transitorio" | "Stabile" | null) => {
setCookie(COOKIE_KEY, "true", { setCookie(COOKIE_KEY, "true", {
expires: add(new Date(), { days: 30 }), // Set cookie to expire in 30 days expires: add(new Date(), { days: 30 }), // Set cookie to expire in 30 days
}); });
if (v !== null) { if (v !== null) {
window.location.assign("/annunci?tipo=" + v); window.location.assign(`/annunci?tipo=${v}`);
} }
setOpen(false); setOpen(false);
}; };
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (path == "/annunci" && !hasCookie) { if (path === "/annunci" && !hasCookie) {
const timer = setTimeout(() => { const timer = setTimeout(() => {
setOpen(true); setOpen(true);
}, 1000); // 1 second delay }, 1000); // 1 second delay
return () => clearTimeout(timer); return () => clearTimeout(timer);
} }
}, [path, hasCookie]); }, [path, hasCookie]);
return ( return (
<Dialog <Dialog
open={open} open={open}
onOpenChange={async (v) => { onOpenChange={async (v) => {
setOpen(v); setOpen(v);
if (!v) { if (!v) {
await update(null); await update(null);
} }
}} }}
> >
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="info" className="flex items-center gap-2"> <Button variant="info" className="flex items-center gap-2">
<CircleHelp />{" "} <CircleHelp />{" "}
<span className="hidden sm:block"> <span className="hidden sm:block">
{t.tipologia_tutorial.btn_title} {t.tipologia_tutorial.btn_title}
</span> </span>
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-2xl"> <DialogTitle className="text-2xl">
{t.tipologia_tutorial.title} {t.tipologia_tutorial.title}
</DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> <DialogDescription className="sr-only">
tipologia hint tipologia hint
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-8"> <div className="flex flex-col gap-8">
<div className="border-transitorio space-y-2 rounded-md border p-2"> <div className="border-transitorio space-y-2 rounded-md border p-2">
<h3 className="text-transitorio text-lg font-semibold"> <h3 className="text-transitorio text-lg font-semibold">
{t.tipologia_tutorial.transitorio} {t.tipologia_tutorial.transitorio}
</h3> </h3>
<p>{t.tipologia_tutorial.transitorio_desc}</p> <p>{t.tipologia_tutorial.transitorio_desc}</p>
<Button <Button
aria-label={t.tipologia_tutorial.transitorio_cta} aria-label={t.tipologia_tutorial.transitorio_cta}
onClick={async () => await update("Transitorio")} onClick={async () => await update("Transitorio")}
className="bg-transitorio flex w-full items-center gap-2" className="bg-transitorio flex w-full items-center gap-2"
> >
{t.tipologia_tutorial.transitorio_cta} {t.tipologia_tutorial.transitorio_cta}
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</Button> </Button>
</div> </div>
<div className="border-stabile space-y-2 rounded-md border p-2"> <div className="border-stabile space-y-2 rounded-md border p-2">
<h3 className="text-stabile text-lg font-semibold"> <h3 className="text-stabile text-lg font-semibold">
{t.tipologia_tutorial.stabile} {t.tipologia_tutorial.stabile}
</h3> </h3>
<p>{t.tipologia_tutorial.stabile_desc}</p> <p>{t.tipologia_tutorial.stabile_desc}</p>
<Button <Button
aria-label={t.tipologia_tutorial.stabile_cta} aria-label={t.tipologia_tutorial.stabile_cta}
onClick={async () => await update("Stabile")} onClick={async () => await update("Stabile")}
className="bg-stabile flex w-full items-center gap-2" className="bg-stabile flex w-full items-center gap-2"
> >
{t.tipologia_tutorial.stabile_cta} {t.tipologia_tutorial.stabile_cta}
<ArrowRight className="size-4" /> <ArrowRight className="size-4" />
</Button> </Button>
</div> </div>
<Button <Button
aria-label={t.tipologia_tutorial.alone} aria-label={t.tipologia_tutorial.alone}
variant="outline" variant="outline"
onClick={async () => await update(null)} onClick={async () => await update(null)}
className="flex w-full items-center gap-2" className="flex w-full items-center gap-2"
> >
{t.tipologia_tutorial.alone} {t.tipologia_tutorial.alone}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };

View file

@ -1,208 +1,208 @@
import { ArrowRight, BadgePlus, LogIn } from "lucide-react"; import { ArrowRight, BadgePlus, LogIn } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button";
import { useRouter } from "next/router";
import { api } from "~/utils/api";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router";
import toast from "react-hot-toast";
import { ContattoAnnuncio } from "~/components/annuncio-interactions/contatto_modal";
import LoadingButton from "~/components/custom_ui/loading-button";
import { Button, buttonVariants } from "~/components/ui/button";
import { cn } from "~/lib/utils";
import { useAnnuncio } from "~/providers/AnnuncioProvider"; import { useAnnuncio } from "~/providers/AnnuncioProvider";
import type { SessionContextType } from "~/providers/SessionProvider"; import type { SessionContextType } from "~/providers/SessionProvider";
import LoadingButton from "~/components/custom_ui/loading-button";
import type { UsersId } from "~/schemas/public/Users";
import type { AnnunciId } from "~/schemas/public/Annunci"; import type { AnnunciId } from "~/schemas/public/Annunci";
import { ContattoAnnuncio } from "~/components/annuncio-interactions/contatto_modal"; import type { UsersId } from "~/schemas/public/Users";
import toast from "react-hot-toast"; import { api } from "~/utils/api";
export const AnnuncioInteractions = ({ export const AnnuncioInteractions = ({
session, session,
disabled, disabled,
}: { }: {
session: SessionContextType; session: SessionContextType;
disabled?: boolean; disabled?: boolean;
}) => { }) => {
const router = useRouter(); const router = useRouter();
const { id, tipo } = useAnnuncio(); const { id, tipo } = useAnnuncio();
if (disabled) { if (disabled) {
return ( return (
<div className="w-full rounded-md bg-red-500 p-2 text-center text-base font-semibold text-white"> <div className="w-full rounded-md bg-red-500 p-2 text-center text-base font-semibold text-white">
Richieste Temporaneamente disattivate Richieste Temporaneamente disattivate
</div> </div>
); );
} }
return ( return (
<div className="flex w-full flex-col justify-around gap-3 md:gap-5"> <div className="flex w-full flex-col justify-around gap-3 md:gap-5">
{session.user ? ( {session.user ? (
tipo ? ( tipo ? (
<ServizioInteraction <ServizioInteraction
userId={session.user.id} userId={session.user.id}
tipologia={tipo} tipologia={tipo}
annuncioId={id} annuncioId={id}
/> />
) : ( ) : (
<span>Errore</span> <span>Errore</span>
) )
) : ( ) : (
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<Link <Link
aria-label="Login" aria-label="Login"
className={cn(buttonVariants({ variant: "default" }))} className={cn(buttonVariants({ variant: "default" }))}
href={{ href={{
pathname: "/login", pathname: "/login",
query: { redirect: router.asPath }, query: { redirect: router.asPath },
}} }}
> >
<LogIn className="mr-1 size-7" /> Accedi se hai un account <LogIn className="mr-1 size-7" /> Accedi se hai un account
</Link> </Link>
<span className="w-full text-center text-sm">oppure</span> <span className="w-full text-center text-sm">oppure</span>
<ContattoAnnuncio /> <ContattoAnnuncio />
</div> </div>
)} )}
</div> </div>
); );
}; };
const ServizioInteraction = ({ const ServizioInteraction = ({
userId, userId,
tipologia, tipologia,
annuncioId, annuncioId,
}: { }: {
userId: UsersId; userId: UsersId;
tipologia: string; tipologia: string;
annuncioId: AnnunciId; annuncioId: AnnunciId;
}) => { }) => {
const { data: servizio, isLoading } = const { data: servizio, isLoading } =
api.servizio.AnnuncioServizioTipologiaMatch.useQuery({ api.servizio.AnnuncioServizioTipologiaMatch.useQuery({
userId, userId,
tipologia, tipologia,
annuncioId, annuncioId,
}); });
if (isLoading) return <LoadingButton loading className="w-full" />; if (isLoading) return <LoadingButton loading className="w-full" />;
if ( if (
servizio == undefined || servizio === undefined ||
servizio.status == "invalid" || servizio.status === "invalid" ||
servizio.status == "not_found" servizio.status === "not_found"
) { ) {
return ( return (
<div> <div>
<p>Non hai un servizio attivo</p> <p>Non hai un servizio attivo</p>
</div> </div>
); );
} }
if (servizio.status == "already_saved") { if (servizio.status === "already_saved") {
return ( return (
<div className="flex w-full flex-col gap-2"> <div className="flex w-full flex-col gap-2">
<div className="w-full text-center font-semibold text-green-500"> <div className="w-full text-center font-semibold text-green-500">
Annuncio già salvato! Annuncio già salvato!
</div> </div>
<Link <Link
href={`/area-riservata/dashboard`} href={`/area-riservata/dashboard`}
aria-label="Vai alla tua area riservata" aria-label="Vai alla tua area riservata"
> >
<Button variant="success" className="flex w-full items-center gap-2"> <Button variant="success" className="flex w-full items-center gap-2">
Vai alla tua area riservata <ArrowRight /> Vai alla tua area riservata <ArrowRight />
</Button> </Button>
</Link> </Link>
</div> </div>
); );
} }
return <InteressatoButton annuncioId={annuncioId} />; return <InteressatoButton annuncioId={annuncioId} />;
}; };
export const InteressatoButton = ({ export const InteressatoButton = ({
annuncioId, annuncioId,
}: { }: {
annuncioId: AnnunciId; annuncioId: AnnunciId;
}) => { }) => {
const { data: hasInterest, isLoading } = const { data: hasInterest, isLoading } =
api.intrests.hasUserInterest.useQuery({ api.intrests.hasUserInterest.useQuery({
annuncioId, annuncioId,
}); });
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: add } = api.intrests.addIntrest.useMutation({ const { mutate: add } = api.intrests.addIntrest.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.intrests.hasUserInterest.invalidate({ await utils.intrests.hasUserInterest.invalidate({
annuncioId, annuncioId,
}); });
toast.success("Richiesta di interesse inviata con successo!"); toast.success("Richiesta di interesse inviata con successo!");
}, },
}); });
if (isLoading || hasInterest === undefined) { if (isLoading || hasInterest === undefined) {
return ( return (
<Button className="flex w-full items-center gap-2" disabled> <Button className="flex w-full items-center gap-2" disabled>
<BadgePlus className="size-6" /> <BadgePlus className="size-6" />
Caricamento... Caricamento...
</Button> </Button>
); );
} }
if (hasInterest) { if (hasInterest) {
return ( return (
<span className="flex w-full items-center justify-center gap-2 rounded-md bg-green-500 p-2 text-sm font-semibold text-white"> <span className="flex w-full items-center justify-center gap-2 rounded-md bg-green-500 p-2 text-sm font-semibold text-white">
<BadgePlus className="inline size-5" /> <BadgePlus className="inline size-5" />
Hai già mandato una richiesta Hai già mandato una richiesta
</span> </span>
); );
} }
return ( return (
<Button <Button
className="flex w-full items-center gap-2" className="flex w-full items-center gap-2"
variant="info" variant="info"
//size="xl" //size="xl"
aria-label="Sono interessato" aria-label="Sono interessato"
onClick={() => { onClick={() => {
add({ add({
annuncioId, annuncioId,
}); });
}} }}
> >
<BadgePlus className="size-6" /> <BadgePlus className="size-6" />
Sono interessato Sono interessato
</Button> </Button>
); );
}; };
export const InteressatoButtonBatchV = ({ export const InteressatoButtonBatchV = ({
annuncioId, annuncioId,
hasInterest, hasInterest,
}: { }: {
annuncioId: AnnunciId; annuncioId: AnnunciId;
hasInterest: boolean; hasInterest: boolean;
}) => { }) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: add } = api.intrests.addIntrest.useMutation({ const { mutate: add } = api.intrests.addIntrest.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.intrests.getUserInterests.invalidate(); await utils.intrests.getUserInterests.invalidate();
toast.success("Richiesta di interesse inviata con successo!"); toast.success("Richiesta di interesse inviata con successo!");
}, },
}); });
if (hasInterest) { if (hasInterest) {
return ( return (
<span className="flex w-full items-center justify-center gap-2 rounded-md bg-green-500 p-2 text-sm font-semibold text-white"> <span className="flex w-full items-center justify-center gap-2 rounded-md bg-green-500 p-2 text-sm font-semibold text-white">
<BadgePlus className="inline size-5" /> <BadgePlus className="inline size-5" />
Hai già mandato una richiesta Hai già mandato una richiesta
</span> </span>
); );
} }
return ( return (
<Button <Button
className="flex w-full items-center gap-2" className="flex w-full items-center gap-2"
variant="info" variant="info"
//size="xl" //size="xl"
aria-label="Sono interessato" aria-label="Sono interessato"
onClick={() => { onClick={() => {
add({ add({
annuncioId, annuncioId,
}); });
}} }}
> >
<BadgePlus className="size-6" /> <BadgePlus className="size-6" />
Sono interessato Sono interessato
</Button> </Button>
); );
}; };

View file

@ -1,41 +1,41 @@
import { useAnnuncio } from "~/providers/AnnuncioProvider";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import { FormContattoAnnucio } from "~/forms/FormContattoAnnuncio";
import { Button } from "~/components/ui/button";
import { MessageCircleQuestion } from "lucide-react"; import { MessageCircleQuestion } from "lucide-react";
import {
Credenza,
CredenzaBody,
CredenzaContent,
CredenzaDescription,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import { Button } from "~/components/ui/button";
import { FormContattoAnnucio } from "~/forms/FormContattoAnnuncio";
import { useAnnuncio } from "~/providers/AnnuncioProvider";
export const ContattoAnnuncio = () => { export const ContattoAnnuncio = () => {
const { codice } = useAnnuncio(); const { codice } = useAnnuncio();
return ( return (
<Credenza> <Credenza>
<CredenzaTrigger asChild> <CredenzaTrigger asChild>
<Button <Button
variant="destructive" variant="destructive"
className="flex w-full items-center gap-2" className="flex w-full items-center gap-2"
> >
<MessageCircleQuestion className="size-6" /> Contattaci <MessageCircleQuestion className="size-6" /> Contattaci
</Button> </Button>
</CredenzaTrigger> </CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]"> <CredenzaContent className="max-h-[90vh]">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle>Contattaci per avere informazioni</CredenzaTitle> <CredenzaTitle>Contattaci per avere informazioni</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
Ti contatteremo il prima possibile per darti più informazioni Ti contatteremo il prima possibile per darti più informazioni
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5"> <CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<FormContattoAnnucio codice={codice} /> <FormContattoAnnucio codice={codice} />
</CredenzaBody> </CredenzaBody>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
); );
}; };

View file

@ -1,307 +1,304 @@
import Link from "next/link";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import { cn, formatCurrency } from "~/lib/utils";
import { BedDouble, CalendarClock, Maximize2, Ruler } from "lucide-react"; import { BedDouble, CalendarClock, Maximize2, Ruler } from "lucide-react";
import Image from "next/image"; import Image from "next/image";
import { useTranslation } from "~/providers/I18nProvider"; import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { ImageFlbk } from "~/components/ImageWithFallback";
import { import {
Dialog, Carousel,
DialogContent, CarouselContent,
DialogDescription, CarouselItem,
DialogHeader, CarouselNext,
DialogTitle, CarouselPrevious,
} from "~/components/ui/carousel";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { camereTesti, handleConsegna } from "~/lib/annuncio_details"; import { camereTesti, handleConsegna } from "~/lib/annuncio_details";
import { ImageFlbk } from "~/components/ImageWithFallback"; import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
type CardAnnuncioProps = { type CardAnnuncioProps = {
id: number; id: number;
codice: string; codice: string;
prezzo: number; prezzo: number;
titolo: string | null; titolo: string | null;
mq: string | null; mq: string | null;
comune: string | null; comune: string | null;
provincia: string | null; provincia: string | null;
consegna: number | null; consegna: number | null;
camere: number | null; camere: number | null;
immagini: string[] | undefined; immagini: string[] | undefined;
tipo: string | null; tipo: string | null;
stato: string | null; stato: string | null;
className?: string; className?: string;
}; };
export const CardAnnuncio = ({ export const CardAnnuncio = ({
id, id,
codice, codice,
prezzo, prezzo,
titolo, titolo,
mq, mq,
comune, comune,
provincia, provincia,
consegna, consegna,
camere, camere,
immagini, immagini,
tipo, tipo,
stato, stato,
className, className,
}: CardAnnuncioProps) => { }: CardAnnuncioProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
className={cn( className={cn(
"h-[464px] rounded-xl bg-white shadow-sm shadow-neutral-100 outline outline-neutral-200 hover:shadow-md hover:outline-neutral-300", "h-[464px] rounded-xl bg-white shadow-sm shadow-neutral-100 outline outline-neutral-200 hover:shadow-md hover:outline-neutral-300",
className, className,
)} )}
id={`card-annuncio-${id}`} id={`card-annuncio-${id}`}
> >
<Link <Link
href={`/annuncio/${codice}`} href={`/annuncio/${codice}`}
//target="_blank" //target="_blank"
className="block p-2" //duration-700 ease-in-out animate-in fade-in className="block p-2" //duration-700 ease-in-out animate-in fade-in
> >
<div className="group relative rounded-xl text-clip"> <div className="group relative rounded-xl text-clip">
{stato == "Trattativa" && ( {stato === "Trattativa" && (
<div> <div>
<div className="absolute z-20 h-56 w-full"> <div className="absolute z-20 h-56 w-full">
<div className="absolute bottom-0 w-full touch-none bg-violet-500 text-center text-lg text-white select-none"> <div className="absolute bottom-0 w-full touch-none bg-violet-500 text-center text-lg text-white select-none">
Annuncio in Trattativa Annuncio in Trattativa
</div> </div>
</div> </div>
</div> </div>
)} )}
<Carousel opts={{ loop: true }}> <Carousel opts={{ loop: true }}>
<CarouselContent> <CarouselContent>
{immagini && {immagini?.map((img, idx) => (
immagini.map((img, idx) => ( <CarouselItem key={`${img}-${idx}`}>
<CarouselItem key={`${img}-${idx}`}> <ImageFlbk
<ImageFlbk alt={t.card.alt_immagine}
alt={t.card.alt_immagine} width={800}
width={800} height={400}
height={400} src={img}
src={img} priority={idx === 0}
priority={idx === 0} className={
className={ "h-56 w-full rounded-xl object-cover outline outline-neutral-200"
"h-56 w-full rounded-xl object-cover outline outline-neutral-200" }
} />
/> </CarouselItem>
</CarouselItem> ))}
))} </CarouselContent>
</CarouselContent>
<div <div
onKeyDown={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}
role="button" role="button"
tabIndex={0} tabIndex={0}
onClick={(e) => e.preventDefault()} onClick={(e) => e.preventDefault()}
className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0" className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0"
> >
<CarouselPrevious <CarouselPrevious
className="left-2 cursor-pointer" className="left-2 cursor-pointer"
disabled={!immagini || immagini.length === 1} disabled={!immagini || immagini.length === 1}
/> />
<CarouselNext <CarouselNext
className="right-2 cursor-pointer" className="right-2 cursor-pointer"
disabled={!immagini || immagini.length === 1} disabled={!immagini || immagini.length === 1}
/> />
</div> </div>
</Carousel> </Carousel>
</div> </div>
<div <div
className={cn( className={cn(
"mt-2 flex w-full items-center justify-center rounded-md", "mt-2 flex w-full items-center justify-center rounded-md",
tipo === "Transitorio" && "bg-transitorio", tipo === "Transitorio" && "bg-transitorio",
tipo === "Stabile" && "bg-stabile", tipo === "Stabile" && "bg-stabile",
tipo === "Cessione" && "bg-blue-400", tipo === "Cessione" && "bg-blue-400",
tipo === "Vendita" && "bg-green-400", tipo === "Vendita" && "bg-green-400",
)} )}
> >
<div className="font-semibold text-white">{tipo}</div> <div className="font-semibold text-white">{tipo}</div>
</div> </div>
<div className="mt-2 space-y-2"> <div className="mt-2 space-y-2">
<div className="flex flex-row justify-around"> <div className="flex flex-row justify-around">
<div className="text-center"> <div className="text-center">
<span className="sr-only">{t.card.codice}</span> <span className="sr-only">{t.card.codice}</span>
<span className="text-xl font-semibold text-red-600"> <span className="text-xl font-semibold text-red-600">
Cod: {codice} Cod: {codice}
</span> </span>
</div> </div>
<div className="text-center"> <div className="text-center">
<span className="sr-only">{t.card.prezzo}</span> <span className="sr-only">{t.card.prezzo}</span>
<span className="text-xl font-semibold"> <span className="text-xl font-semibold">
{formatCurrency(prezzo / 1e2)} {formatCurrency(prezzo / 1e2)}
</span> </span>
</div> </div>
</div> </div>
<div className="text-muted-foreground mb-3 text-center"> <div className="text-muted-foreground mb-3 text-center">
<span className="sr-only">{t.card.titolo}</span> <span className="sr-only">{t.card.titolo}</span>
<span className="line-clamp-2 min-h-[3.5rem] text-base font-medium md:text-lg"> <span className="line-clamp-2 min-h-[3.5rem] text-base font-medium md:text-lg">
{titolo} {titolo}
</span> </span>
</div> </div>
<div className="text-muted-foreground text-center"> <div className="text-muted-foreground text-center">
<span className="sr-only">{t.card.indirizzo}</span> <span className="sr-only">{t.card.indirizzo}</span>
<span className="text-sm font-medium"> <span className="text-sm font-medium">
{comune && provincia && `${comune} (${provincia})`} {comune && provincia && `${comune} (${provincia})`}
</span> </span>
</div> </div>
</div> </div>
<div className="text-muted-foreground mt-4 flex items-center justify-around gap-2 text-xs"> <div className="text-muted-foreground mt-4 flex items-center justify-around gap-2 text-xs">
<div className="flex w-full items-center justify-center gap-1 rounded-md bg-neutral-100 py-3"> <div className="flex w-full items-center justify-center gap-1 rounded-md bg-neutral-100 py-3">
<Ruler className="size-4 text-indigo-700" /> <Ruler className="size-4 text-indigo-700" />
<div> <div>
<p className="font-medium"> <p className="font-medium">
{mq ? Number.parseFloat(mq) : 0}{" "} {mq ? Number.parseFloat(mq) : 0}{" "}
<span> <span>
m<sup>2</sup> m<sup>2</sup>
</span> </span>
</p> </p>
</div> </div>
</div> </div>
<div className="flex w-full items-center justify-center gap-1 rounded-md bg-neutral-100 py-3"> <div className="flex w-full items-center justify-center gap-1 rounded-md bg-neutral-100 py-3">
<BedDouble className="size-4 text-indigo-700" /> <BedDouble className="size-4 text-indigo-700" />
<div> <div>
<p className="font-medium"> <p className="font-medium">
{camereTesti({ camere: camere, testi: t.card })} {camereTesti({ camere: camere, testi: t.card })}
</p> </p>
</div> </div>
</div> </div>
<div className="flex w-full items-center justify-center gap-1 rounded-md bg-neutral-100 py-3"> <div className="flex w-full items-center justify-center gap-1 rounded-md bg-neutral-100 py-3">
<CalendarClock className="size-4 text-indigo-700" /> <CalendarClock className="size-4 text-indigo-700" />
<div> <div>
<p className="truncate font-medium"> <p className="truncate font-medium">
{handleConsegna({ {handleConsegna({
consegna: consegna, consegna: consegna,
consegna_da: t.card.consegna_da, consegna_da: t.card.consegna_da,
mesi: t.preferenze.mesi, mesi: t.preferenze.mesi,
subito: t.card.consegna_subito, subito: t.card.consegna_subito,
aggiornamento: t.card.in_aggiornamento, aggiornamento: t.card.in_aggiornamento,
})} })}
</p> </p>
</div> </div>
</div> </div>
</div> </div>
</Link> </Link>
</div> </div>
); );
}; };
export const CarouselAnnuncio = ({ export const CarouselAnnuncio = ({
single, single,
immagini, immagini,
}: { }: {
single?: boolean; single?: boolean;
immagini: string[] | null; immagini: string[] | null;
}) => { }) => {
const [openModal, setOpenModal] = useState(false); const [openModal, setOpenModal] = useState(false);
const [idxModal, setIdxModal] = useState(0); const [idxModal, setIdxModal] = useState(0);
function handleOpenModal(position: number) { function handleOpenModal(position: number) {
setIdxModal(position); setIdxModal(position);
setOpenModal(true); setOpenModal(true);
} }
return ( return (
<> <>
<div className="mx-auto my-4 w-full rounded-md text-clip"> <div className="mx-auto my-4 w-full rounded-md text-clip">
<div className="relative"> <div className="relative">
<Carousel opts={{ loop: true, align: "start" }}> <Carousel opts={{ loop: true, align: "start" }}>
<CarouselContent> <CarouselContent>
{immagini && {immagini?.map((img, idx) => (
immagini.map((img, idx) => ( <CarouselItem
<CarouselItem key={`${img}-${idx}-cF`}
key={`${img}-${idx}-cF`} className={cn(
className={cn( "group relative",
"group relative", immagini && immagini.length === 1
immagini && immagini.length === 1 ? ""
? "" : !single && "md:basis-1/2 lg:basis-1/3",
: !single && "md:basis-1/2 lg:basis-1/3", )}
)} >
> <ImageFlbk
<ImageFlbk priority={idx === 0}
priority={idx === 0} alt={img}
alt={img} width={800}
width={800} height={400}
height={400} onClick={() => handleOpenModal(idx)}
onClick={() => handleOpenModal(idx)} src={img}
src={img} className={cn(
className={cn( "h-72 w-full rounded-md object-cover",
"h-72 w-full rounded-md object-cover", immagini.length === 1 && "object-contain",
immagini.length === 1 && "object-contain", )}
)} />
/> <Maximize2
<Maximize2 onClick={() => handleOpenModal(idx)}
onClick={() => handleOpenModal(idx)} className="absolute right-2 bottom-2 size-6 rounded-md bg-white/70 stroke-2 p-1 transition-opacity duration-150 group-hover:opacity-100 sm:opacity-0"
className="absolute right-2 bottom-2 size-6 rounded-md bg-white/70 stroke-2 p-1 transition-opacity duration-150 group-hover:opacity-100 sm:opacity-0" />
/> </CarouselItem>
</CarouselItem> ))}
))} </CarouselContent>
</CarouselContent>
<CarouselPrevious <CarouselPrevious
type="button" type="button"
className="left-2 cursor-pointer opacity-60 disabled:opacity-0" className="left-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1} disabled={!immagini || immagini.length === 1}
/> />
<CarouselNext <CarouselNext
type="button" type="button"
className="right-2 cursor-pointer opacity-60 disabled:opacity-0" className="right-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1} disabled={!immagini || immagini.length === 1}
/> />
</Carousel> </Carousel>
</div> </div>
</div> </div>
<Dialog open={openModal} onOpenChange={setOpenModal}> <Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="flex h-full w-full max-w-full items-center justify-center border-none border-transparent bg-white p-0 md:max-w-[80vw] md:p-6 [&_#dialog-close>svg]:size-8"> <DialogContent className="flex h-full w-full max-w-full items-center justify-center border-none border-transparent bg-white p-0 md:max-w-[80vw] md:p-6 [&_#dialog-close>svg]:size-8">
<DialogHeader className="absolute top-0 right-0 left-0 z-10"> <DialogHeader className="absolute top-0 right-0 left-0 z-10">
<DialogTitle className="sr-only">Image Dialog</DialogTitle> <DialogTitle className="sr-only">Image Dialog</DialogTitle>
<DialogDescription className="sr-only">Immagine</DialogDescription> <DialogDescription className="sr-only">Immagine</DialogDescription>
</DialogHeader> </DialogHeader>
<Carousel opts={{ loop: true, startIndex: idxModal }}> <Carousel opts={{ loop: true, startIndex: idxModal }}>
<CarouselContent> <CarouselContent>
{immagini && {immagini?.map((img, idx) => (
immagini.map((img, idx) => ( <CarouselItem key={`${img}-${idx}-cD`}>
<CarouselItem key={`${img}-${idx}-cD`}> <Image
<Image alt={`carousel-img-${idx}`}
alt={`carousel-img-${idx}`} width={1920}
width={1920} height={1080}
height={1080} src={img}
src={img} className="mx-auto h-[90vh] w-full rounded-md object-contain sm:w-[80vw]"
className="mx-auto h-[90vh] w-full rounded-md object-contain sm:w-[80vw]" />
/> </CarouselItem>
</CarouselItem> ))}
))} </CarouselContent>
</CarouselContent>
<CarouselPrevious <CarouselPrevious
type="button" type="button"
className="left-2 cursor-pointer opacity-60 disabled:opacity-0" className="left-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1} disabled={!immagini || immagini.length === 1}
/> />
<CarouselNext <CarouselNext
type="button" type="button"
className="right-2 cursor-pointer opacity-60 disabled:opacity-0" className="right-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1} disabled={!immagini || immagini.length === 1}
/> />
</Carousel> </Carousel>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
); );
}; };

View file

@ -1,383 +1,383 @@
import { format } from "date-fns";
import {
Download,
File,
FileArchive,
FileAudio,
FileCode,
FileSpreadsheet,
FileText,
FileVideo,
ImageIcon,
MoreVertical,
Pencil,
Trash2,
TriangleAlert,
} from "lucide-react";
import Link from "next/link";
import { type FormEvent, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import Input from "~/components/custom_ui/input";
import { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Separator } from "~/components/ui/separator";
import { UploadModal } from "~/components/upload_modal";
import { handleDownload } from "~/hooks/filesHooks";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { import type {
Storageindex, Storageindex,
StorageindexId, StorageindexId,
} from "~/schemas/public/Storageindex"; } from "~/schemas/public/Storageindex";
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 { LoadingPage } from "~/components/loading";
import { Status500 } from "~/components/status-page";
import { Separator } from "~/components/ui/separator";
import Link from "next/link";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import {
Download,
File,
FileArchive,
FileAudio,
FileCode,
FileSpreadsheet,
FileText,
FileVideo,
ImageIcon,
MoreVertical,
Pencil,
Trash2,
TriangleAlert,
} from "lucide-react";
import { useState, type FormEvent } from "react";
import { useTranslation } from "~/providers/I18nProvider";
import Input from "~/components/custom_ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { handleDownload } from "~/hooks/filesHooks";
import { UploadModal } from "~/components/upload_modal";
import { format } from "date-fns";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
export const AllegatiComp = ({ export const AllegatiComp = ({
userId, userId,
isAdmin, isAdmin,
}: { }: {
userId: UsersId; userId: UsersId;
isAdmin: boolean; isAdmin: boolean;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: allegati, isLoading: isLoadingAllegati } = const { data: allegati, isLoading: isLoadingAllegati } =
api.storage.getUserStorage.useQuery({ userId: userId }); api.storage.getUserStorage.useQuery({ userId: userId });
const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation(); const { mutateAsync: getToken } = api.storage.getStorageToken.useMutation();
const { mutate: deleteFile } = api.storage.deleteUserStorage.useMutation({ const { mutate: deleteFile } = api.storage.deleteUserStorage.useMutation({
onMutate: () => { onMutate: () => {
const toastId = toast.loading(t.caricamento, { const toastId = toast.loading(t.caricamento, {
icon: "🪛", icon: "🪛",
}); });
return { toastId }; return { toastId };
}, },
onSuccess: async (_error, _variables, context) => { onSuccess: async (_error, _variables, context) => {
await utils.storage.getUserStorage.invalidate({ await utils.storage.getUserStorage.invalidate({
userId: userId, userId: userId,
}); });
toast.success(t.allegati.allegato_rimosso, { toast.success(t.allegati.allegato_rimosso, {
icon: "🗑️", icon: "🗑️",
id: context?.toastId, id: context?.toastId,
}); });
}, },
}); });
const { mutate: renameFile } = api.storage.renameFile.useMutation({ const { mutate: renameFile } = api.storage.renameFile.useMutation({
onMutate: () => { onMutate: () => {
setEditOpen(false); setEditOpen(false);
const toastId = toast.loading(t.caricamento, { const toastId = toast.loading(t.caricamento, {
icon: "🪛", icon: "🪛",
}); });
return { toastId }; return { toastId };
}, },
onSuccess: async (_error, _variables, context) => { onSuccess: async (_error, _variables, context) => {
await utils.storage.getUserStorage.invalidate({ await utils.storage.getUserStorage.invalidate({
userId: userId, userId: userId,
}); });
toast.success(t.successo, { toast.success(t.successo, {
icon: "👍", icon: "👍",
id: context?.toastId, id: context?.toastId,
}); });
}, },
}); });
const [editData, setEditData] = useState<{ const [editData, setEditData] = useState<{
id: StorageindexId; id: StorageindexId;
filename: string; filename: string;
} | null>(null); } | null>(null);
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const handleEditSubmit = (e: FormEvent<HTMLFormElement>) => { const handleEditSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const form = e.currentTarget; const form = e.currentTarget;
const formData = new FormData(form); const formData = new FormData(form);
if (!formData.get("id") || !formData.get("filename")) { if (!formData.get("id") || !formData.get("filename")) {
return; return;
} }
renameFile({ renameFile({
fileId: formData.get("id") as StorageindexId, fileId: formData.get("id") as StorageindexId,
name: formData.get("filename") as string, name: formData.get("filename") as string,
}); });
}; };
const utils = api.useUtils(); const utils = api.useUtils();
if (isLoadingAllegati) return <LoadingPage />; if (isLoadingAllegati) return <LoadingPage />;
if (!allegati) return <Status500 />; if (!allegati) return <Status500 />;
const from_admin = allegati.filter((a) => a.from_admin); const from_admin = allegati.filter((a) => a.from_admin);
const not_from_admin = allegati.filter((a) => !a.from_admin); const not_from_admin = allegati.filter((a) => !a.from_admin);
return ( return (
<div className="flex flex-col space-y-5"> <div className="flex flex-col space-y-5">
<h1 className="text-primary text-2xl font-bold">{t.allegati.title}</h1> <h1 className="text-primary text-2xl font-bold">{t.allegati.title}</h1>
<h1 className="text-xl font-semibold">{t.allegati.da_info}</h1> <h1 className="text-xl font-semibold">{t.allegati.da_info}</h1>
{from_admin && ( {from_admin && (
<FileList <FileList
files={from_admin} files={from_admin}
isAdmin={isAdmin} isAdmin={isAdmin}
selectHandler={(id, filename) => { selectHandler={(id, filename) => {
setEditData({ id, filename }); setEditData({ id, filename });
setEditOpen(true); setEditOpen(true);
}} }}
delete_onClick={(id) => deleteFile({ userId, fileId: id })} delete_onClick={(id) => deleteFile({ userId, fileId: id })}
download_onClick={(file) => download_onClick={(file) =>
handleDownload({ handleDownload({
file, file,
getToken: async () => await getToken(), getToken: async () => await getToken(),
}) })
} }
/> />
)} )}
<Separator /> <Separator />
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<UploadModal userId={userId} isAdmin={isAdmin} />{" "} <UploadModal userId={userId} isAdmin={isAdmin} />{" "}
<span className="text-sm">Max 5 MB per file</span> <span className="text-sm">Max 5 MB per file</span>
</div> </div>
<div className="space-y-2 rounded-md border border-orange-500 p-2"> <div className="space-y-2 rounded-md border border-orange-500 p-2">
<div className="flex items-center gap-2 text-orange-500"> <div className="flex items-center gap-2 text-orange-500">
<TriangleAlert className="size-5" /> <TriangleAlert className="size-5" />
{t.importante}: {t.importante}:
</div> </div>
<span className="text-sm">{t.allegati.disclamer}</span> <span className="text-sm">{t.allegati.disclamer}</span>
</div> </div>
{not_from_admin && ( {not_from_admin && (
<FileList <FileList
files={not_from_admin} files={not_from_admin}
isAdmin={isAdmin} isAdmin={isAdmin}
selectHandler={(id, filename) => { selectHandler={(id, filename) => {
setEditData({ id, filename }); setEditData({ id, filename });
setEditOpen(true); setEditOpen(true);
}} }}
delete_onClick={(id) => deleteFile({ userId, fileId: id })} delete_onClick={(id) => deleteFile({ userId, fileId: id })}
download_onClick={(file) => download_onClick={(file) =>
handleDownload({ handleDownload({
file, file,
getToken: async () => await getToken(), getToken: async () => await getToken(),
}) })
} }
/> />
)} )}
<EditFileDialog <EditFileDialog
open={editOpen} open={editOpen}
onOpenChange={setEditOpen} onOpenChange={setEditOpen}
data={editData} data={editData}
handleEditSubmit={handleEditSubmit} handleEditSubmit={handleEditSubmit}
/> />
</div> </div>
); );
}; };
export const ExtIcon = ({ export const ExtIcon = ({
ext, ext,
className, className,
}: { }: {
ext: string | null | undefined; ext: string | null | undefined;
className?: string; className?: string;
}) => { }) => {
if (!ext) return <File className={cn("size-5 text-gray-500", className)} />; if (!ext) return <File className={cn("size-5 text-gray-500", className)} />;
if (ext.includes("pdf")) { if (ext.includes("pdf")) {
return <FileText className={cn("size-5 text-red-500", className)} />; return <FileText className={cn("size-5 text-red-500", className)} />;
} else if ( } else if (
ext.includes("image") || ext.includes("image") ||
["jpg", "jpeg", "png", "gif", "svg", "webp"].some((ext) => ["jpg", "jpeg", "png", "gif", "svg", "webp"].some((ext) =>
ext.includes(ext), ext.includes(ext),
) )
) { ) {
return <ImageIcon className={cn("size-5 text-blue-500", className)} />; return <ImageIcon className={cn("size-5 text-blue-500", className)} />;
} else if ( } else if (
["html", "css", "js", "jsx", "ts", "tsx", "json", "xml"].some((ext) => ["html", "css", "js", "jsx", "ts", "tsx", "json", "xml"].some((ext) =>
ext.includes(ext), ext.includes(ext),
) )
) { ) {
return <FileCode className={cn("size-5 text-emerald-500", className)} />; return <FileCode className={cn("size-5 text-emerald-500", className)} />;
} else if (["xls", "xlsx", "csv"].some((ext) => ext.includes(ext))) { } else if (["xls", "xlsx", "csv"].some((ext) => ext.includes(ext))) {
return ( return (
<FileSpreadsheet className={cn("size-5 text-green-600", className)} /> <FileSpreadsheet className={cn("size-5 text-green-600", className)} />
); );
} else if ( } else if (
["zip", "rar", "7z", "tar", "gz"].some((ext) => ext.includes(ext)) ["zip", "rar", "7z", "tar", "gz"].some((ext) => ext.includes(ext))
) { ) {
return <FileArchive className={cn("size-5 text-amber-600", className)} />; return <FileArchive className={cn("size-5 text-amber-600", className)} />;
} else if (["mp3", "wav", "ogg", "flac"].some((ext) => ext.includes(ext))) { } else if (["mp3", "wav", "ogg", "flac"].some((ext) => ext.includes(ext))) {
return <FileAudio className={cn("size-5 text-purple-500", className)} />; return <FileAudio className={cn("size-5 text-purple-500", className)} />;
} else if ( } else if (
["mp4", "avi", "mov", "wmv", "webm"].some((ext) => ext.includes(ext)) ["mp4", "avi", "mov", "wmv", "webm"].some((ext) => ext.includes(ext))
) { ) {
return <FileVideo className={cn("size-5 text-pink-500", className)} />; return <FileVideo className={cn("size-5 text-pink-500", className)} />;
} else { } else {
return <File className={cn("size-5 text-gray-500", className)} />; return <File className={cn("size-5 text-gray-500", className)} />;
} }
}; };
const EditFileDialog = ({ const EditFileDialog = ({
open, open,
onOpenChange, onOpenChange,
data, data,
handleEditSubmit, handleEditSubmit,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (status: boolean) => void; onOpenChange: (status: boolean) => void;
data: { data: {
id: StorageindexId; id: StorageindexId;
filename: string; filename: string;
} | null; } | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void; handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle> <DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription> <DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader> </DialogHeader>
{data && ( {data && (
<form onSubmit={handleEditSubmit} method="post"> <form onSubmit={handleEditSubmit} method="post">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label htmlFor="id" className="sr-only"> <label htmlFor="id" className="sr-only">
ID ID
</label> </label>
<Input <Input
type="text" type="text"
className="hidden" className="hidden"
name="id" name="id"
value={data.id} value={data.id}
readOnly readOnly
/> />
<label htmlFor="filename" className="sr-only"> <label htmlFor="filename" className="sr-only">
filename filename
</label> </label>
<Input <Input
type="text" type="text"
placeholder="Modifica messaggio" placeholder="Modifica messaggio"
className="mt-0" className="mt-0"
id="filename" id="filename"
name="filename" name="filename"
defaultValue={data.filename || undefined} defaultValue={data.filename || undefined}
/> />
<Button type="submit">{t.salva}</Button> <Button type="submit">{t.salva}</Button>
</div> </div>
</form> </form>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
interface FileListProps { interface FileListProps {
files: Storageindex[]; files: Storageindex[];
delete_onClick: (id: StorageindexId) => void; delete_onClick: (id: StorageindexId) => void;
download_onClick: (file: Storageindex) => void; download_onClick: (file: Storageindex) => void;
isAdmin: boolean; isAdmin: boolean;
selectHandler: (id: StorageindexId, filename: string) => void; selectHandler: (id: StorageindexId, filename: string) => void;
} }
function FileList({ function FileList({
files, files,
delete_onClick, delete_onClick,
download_onClick, download_onClick,
isAdmin, isAdmin,
selectHandler, selectHandler,
}: FileListProps) { }: FileListProps) {
return ( return (
<div className="w-full"> <div className="w-full">
<div className="rounded-md border"> <div className="rounded-md border">
<div className="bg-muted/50 text-muted-foreground grid grid-cols-12 gap-2 p-4 text-sm font-medium"> <div className="bg-muted/50 text-muted-foreground grid grid-cols-12 gap-2 p-4 text-sm font-medium">
<div className="col-span-6 md:col-span-8">File</div> <div className="col-span-6 md:col-span-8">File</div>
<div className="col-span-4 md:col-span-3">Data</div> <div className="col-span-4 md:col-span-3">Data</div>
<div className="col-span-2 text-right md:col-span-1">Azioni</div> <div className="col-span-2 text-right md:col-span-1">Azioni</div>
</div> </div>
<div className="divide-y"> <div className="divide-y">
{files.map((file) => ( {files.map((file) => (
<div <div
key={file.id} key={file.id}
className="hover:bg-muted/50 grid grid-cols-12 items-center gap-2 p-4 text-sm transition-colors" className="hover:bg-muted/50 grid grid-cols-12 items-center gap-2 p-4 text-sm transition-colors"
> >
<div className="col-span-6 flex items-center gap-2 md:col-span-8"> <div className="col-span-6 flex items-center gap-2 md:col-span-8">
<div className="shrink-0"> <div className="shrink-0">
<ExtIcon ext={file.ext} /> <ExtIcon ext={file.ext} />
</div> </div>
<Link <Link
aria-label="Allegato" aria-label="Allegato"
href={`/area-riservata/allegato-view/${file.id}`} href={`/area-riservata/allegato-view/${file.id}`}
target="_blank" target="_blank"
className="text-foreground truncate font-medium hover:underline" className="text-foreground truncate font-medium hover:underline"
> >
{file.filename} {file.filename}
</Link> </Link>
</div> </div>
<div className="text-muted-foreground col-span-4 md:col-span-3"> <div className="text-muted-foreground col-span-4 md:col-span-3">
{format(file.created_at, "d MMM yyyy")} {format(file.created_at, "d MMM yyyy")}
</div> </div>
<div className="col-span-2 flex justify-end gap-1 md:col-span-1"> <div className="col-span-2 flex justify-end gap-1 md:col-span-1">
<Button <Button
aria-label="Download" aria-label="Download"
variant="ghost" variant="ghost"
size="icon" size="icon"
asChild asChild
className="size-8" className="size-8"
title="Download" title="Download"
> >
<Button <Button
variant="outline" variant="outline"
className="flex gap-1 text-sm" className="flex gap-1 text-sm"
onClick={() => download_onClick(file)} onClick={() => download_onClick(file)}
> >
<Download className="size-4" /> <Download className="size-4" />
</Button> </Button>
</Button> </Button>
{isAdmin && ( {isAdmin && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8"> <Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" /> <MoreVertical className="size-4" />
<span className="sr-only">More options</span> <span className="sr-only">More options</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
aria-label="Rinomina" aria-label="Rinomina"
onClick={() => onClick={() =>
selectHandler(file.id, file.filename || "") selectHandler(file.id, file.filename || "")
} }
> >
<Pencil className="mr-2 size-4" /> <Pencil className="mr-2 size-4" />
Rinomina Rinomina
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
aria-label="Elimina" aria-label="Elimina"
onClick={() => delete_onClick(file.id)} onClick={() => delete_onClick(file.id)}
className="text-destructive focus:text-destructive" className="text-destructive focus:text-destructive"
> >
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 size-4" />
Elimina Elimina
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
} }

View file

@ -1,63 +1,63 @@
import type { UsersId } from "~/schemas/public/Users"; import { useState } from "react";
import { api } from "~/utils/api";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "~/components/ui/accordion"; } from "~/components/ui/accordion";
import { useMediaQuery } from "~/hooks/use-media-query"; import { useMediaQuery } from "~/hooks/use-media-query";
import { useState } from "react"; import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
export const EmailAccordion = ({ userId }: { userId: UsersId }) => { export const EmailAccordion = ({ userId }: { userId: UsersId }) => {
const { data } = api.comunicazioni.getEmails.useQuery({ const { data } = api.comunicazioni.getEmails.useQuery({
userId, userId,
}); });
// Track which accordion item is open // Track which accordion item is open
const [openIndex, setOpenIndex] = useState<string | undefined>(undefined); 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>;
} }
return ( return (
<div className="mx-auto py-4"> <div className="mx-auto py-4">
<Accordion <Accordion
type="single" type="single"
collapsible collapsible
className="space-y-4" className="space-y-4"
value={openIndex} value={openIndex}
onValueChange={setOpenIndex} onValueChange={setOpenIndex}
> >
{data.map((email, index) => ( {data.map((email, index) => (
<AccordionItem <AccordionItem
value={String(index)} value={String(index)}
key={index} key={index}
className="space-y-2 border-none" className="space-y-2 border-none"
> >
<AccordionTrigger className="rounded-md bg-muted px-4 py-2"> <AccordionTrigger className="rounded-md bg-muted px-4 py-2">
Email &ldquo;{email.title}&rdquo; del{" "} Email &ldquo;{email.title}&rdquo; del{" "}
{new Date(email.created_at).toLocaleDateString("it-IT", { {new Date(email.created_at).toLocaleDateString("it-IT", {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
})} })}
</AccordionTrigger> </AccordionTrigger>
<AccordionContent> <AccordionContent>
{openIndex === String(index) && ( {openIndex === String(index) && (
<iframe <iframe
title="Email Content" title="Email Content"
srcDoc={email.html} srcDoc={email.html}
height={mobile ? "600px" : "500px"} height={mobile ? "600px" : "500px"}
width="100%" width="100%"
/> />
)} )}
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
))} ))}
</Accordion> </Accordion>
</div> </div>
); );
}; };

View file

@ -1,86 +1,86 @@
import { api } from "~/utils/api";
import { LoadingPage } from "~/components/loading";
import type { UsersId } from "~/schemas/public/Users";
import { AccordionComp } from "~/components/accordionComp"; import { AccordionComp } from "~/components/accordionComp";
import { useTranslation } from "~/providers/I18nProvider"; import { LoadingPage } from "~/components/loading";
import TimeserieBarChart from "~/components/timeserieBarChart";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import TimeserieBarChartStacked from "~/components/timeserieBarChartStacked";
import { TabRicerca } from "~/components/tables/ricerca-table"; import { TabRicerca } from "~/components/tables/ricerca-table";
import TimeserieBarChart from "~/components/timeserieBarChart";
import TimeserieBarChartStacked from "~/components/timeserieBarChartStacked";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { useTranslation } from "~/providers/I18nProvider";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
export const AdminDashboard = () => { export const AdminDashboard = () => {
const { data: stats, isLoading } = api.stats.adminDashStats.useQuery(); const { data: stats, isLoading } = api.stats.adminDashStats.useQuery();
if (isLoading) return <LoadingPage />; if (isLoading) return <LoadingPage />;
return ( return (
<div className="mx-1 flex-1 overflow-auto"> <div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto pt-4 pb-8"> <div className="mx-auto pt-4 pb-8">
<div className="mx-3 items-start justify-between pb-3 md:flex"> <div className="mx-3 items-start justify-between pb-3 md:flex">
<div className="flex max-w-lg items-center gap-3"> <div className="flex max-w-lg items-center gap-3">
<h3 className="text-2xl font-bold">Admin Dashboard</h3> <h3 className="text-2xl font-bold">Admin Dashboard</h3>
</div> </div>
</div> </div>
{!stats ? ( {!stats ? (
<div className="text-red-500">Errore caricamento statistiche</div> <div className="text-red-500">Errore caricamento statistiche</div>
) : ( ) : (
<div className="grid grid-cols-1 gap-4 px-2 pt-4 sm:grid-cols-2 sm:px-4"> <div className="grid grid-cols-1 gap-4 px-2 pt-4 sm:grid-cols-2 sm:px-4">
<Card className=""> <Card className="">
<CardHeader> <CardHeader>
<CardTitle>Utenti</CardTitle> <CardTitle>Utenti</CardTitle>
<CardDescription> <CardDescription>
Numero totale di utenti registrati Numero totale di utenti registrati
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex h-full items-center justify-center text-6xl font-bold"> <CardContent className="flex h-full items-center justify-center text-6xl font-bold">
{stats.userCount} {stats.userCount}
</CardContent> </CardContent>
</Card> </Card>
<TimeserieBarChart <TimeserieBarChart
title="Utenti registrati" title="Utenti registrati"
timeserie={stats.userTimeserie} timeserie={stats.userTimeserie}
/> />
<TimeserieBarChartStacked <TimeserieBarChartStacked
title="Servizi creati" title="Servizi creati"
valueALabel={stats.serviziTimeserie.valueALabel} valueALabel={stats.serviziTimeserie.valueALabel}
valueBLabel={stats.serviziTimeserie.valueBLabel} valueBLabel={stats.serviziTimeserie.valueBLabel}
timeserie={stats.serviziTimeserie.data} timeserie={stats.serviziTimeserie.data}
/> />
<TimeserieBarChart <TimeserieBarChart
title="Contatti generati" title="Contatti generati"
timeserie={stats.contattiTimeserie} timeserie={stats.contattiTimeserie}
/> />
</div> </div>
)} )}
</div> </div>
</div> </div>
); );
}; };
export const UserDashboard = ({ userId }: { userId: UsersId }) => { export const UserDashboard = ({ userId }: { userId: UsersId }) => {
// const { data: servizi, isLoading: loadingServizi } = // const { data: servizi, isLoading: loadingServizi } =
// api.servizio.getAllServizioAnnunci.useQuery({ userId }); // api.servizio.getAllServizioAnnunci.useQuery({ userId });
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="mx-1 flex-1 overflow-auto"> <div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto pt-4"> <div className="mx-auto pt-4">
<div className="mx-3 items-start justify-between md:flex"> <div className="mx-3 items-start justify-between md:flex">
<div className="flex max-w-lg items-center gap-3"> <div className="flex max-w-lg items-center gap-3">
<h3 className="text-2xl font-bold">Le tue ricerche</h3> <h3 className="text-2xl font-bold">Le tue ricerche</h3>
</div> </div>
</div> </div>
<div className="flex flex-col space-y-5 px-2"> <div className="flex flex-col space-y-5 px-2">
<TabRicerca userId={userId} isAdmin={false} /> <TabRicerca userId={userId} isAdmin={false} />
{/* {loadingServizi ? ( {/* {loadingServizi ? (
<LoadingPage /> <LoadingPage />
) : ( ) : (
servizi?.map((servizio, idx) => ( servizi?.map((servizio, idx) => (
@ -92,12 +92,12 @@ export const UserDashboard = ({ userId }: { userId: UsersId }) => {
/> />
)) ))
)} */} )} */}
</div> </div>
<AccordionComp <AccordionComp
texts={t.area_riservata.dash_accordion_minifaq} texts={t.area_riservata.dash_accordion_minifaq}
className="w-full max-w-full px-2 pt-4" className="w-full max-w-full px-2 pt-4"
/> />
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,214 +1,214 @@
import { CircleCheck, CircleSlash, ReceiptText, Trash2 } from "lucide-react";
import Link from "next/link";
import toast from "react-hot-toast";
import { Confirm } from "~/components/confirm";
import { import {
Credenza, Credenza,
CredenzaContent, CredenzaBody,
CredenzaHeader, CredenzaClose,
CredenzaTitle, CredenzaContent,
CredenzaDescription, CredenzaDescription,
CredenzaBody, CredenzaFooter,
CredenzaFooter, CredenzaHeader,
CredenzaClose, CredenzaTitle,
} from "~/components/custom_ui/credenza"; } from "~/components/custom_ui/credenza";
import { PaymentsTable } from "~/components/tables/payments-table"; import { PaymentsTable } from "~/components/tables/payments-table";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import { api } from "~/utils/api";
import { Confirm } from "~/components/confirm";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { CircleCheck, CircleSlash, ReceiptText, Trash2 } from "lucide-react";
import toast from "react-hot-toast";
import type { UsersId } from "~/schemas/public/Users";
import { cn } from "~/lib/utils";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu"; } from "~/components/ui/dropdown-menu";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum"; import { cn } from "~/lib/utils";
import Link from "next/link";
import OrderTypeEnum from "~/schemas/public/OrderTypeEnum"; import OrderTypeEnum from "~/schemas/public/OrderTypeEnum";
import type { OrdiniOrdineId } from "~/schemas/public/Ordini";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
export const OrdiniModal = ({ export const OrdiniModal = ({
isAdmin, isAdmin,
open, open,
setModalOpen, setModalOpen,
ordineId, ordineId,
userId, userId,
}: { }: {
isAdmin: boolean; isAdmin: boolean;
open: boolean; open: boolean;
setModalOpen: (open: boolean) => void; setModalOpen: (open: boolean) => void;
ordineId: OrdiniOrdineId; ordineId: OrdiniOrdineId;
userId: UsersId | undefined; userId: UsersId | undefined;
}) => { }) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: remove } = api.servizio.removeOrder.useMutation({ const { mutate: remove } = api.servizio.removeOrder.useMutation({
onSuccess: async () => { onSuccess: async () => {
toast.success("Ordine rimosso con successo!"); toast.success("Ordine rimosso con successo!");
await utils.servizio.getOrdini.invalidate({ await utils.servizio.getOrdini.invalidate({
userId, userId,
}); });
setModalOpen(false); setModalOpen(false);
}, },
onError: (error) => { onError: (error) => {
toast.error(`Errore durante la rimozione ordine: ${error.message}`); toast.error(`Errore durante la rimozione ordine: ${error.message}`);
}, },
}); });
const { mutate: update } = api.servizio.updateOrder.useMutation({ const { mutate: update } = api.servizio.updateOrder.useMutation({
onSuccess: async (data) => { onSuccess: async (data) => {
if (data.type === OrderTypeEnum.Acconto && data.isActive) { if (data.type === OrderTypeEnum.Acconto && data.isActive) {
updateServizio({ updateServizio({
servizioId: data.servizio_id, servizioId: data.servizio_id,
data: { data: {
isOkAcconto: true, isOkAcconto: true,
decorrenza: new Date(), decorrenza: new Date(),
}, },
}); });
} }
toast.success("Ordine aggiornato con successo!"); toast.success("Ordine aggiornato con successo!");
await utils.servizio.getOrdini.invalidate({ await utils.servizio.getOrdini.invalidate({
userId, userId,
}); });
await utils.servizio.getOrdineById.invalidate({ await utils.servizio.getOrdineById.invalidate({
ordineId, ordineId,
}); });
}, },
onError: (error) => { onError: (error) => {
toast.error(`Errore durante la modifica: ${error.message}`); toast.error(`Errore durante la modifica: ${error.message}`);
}, },
}); });
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({ const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onSuccess: async () => { onSuccess: async () => {
toast.success("Servizio modificato con successo"); toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate(); await utils.servizio.getUserServizi.invalidate();
}, },
onError: (error) => { onError: (error) => {
toast.error(error.message); toast.error(error.message);
}, },
}); });
const { data } = api.servizio.getOrdineById.useQuery({ const { data } = api.servizio.getOrdineById.useQuery({
ordineId, ordineId,
}); });
if (!data) return null; if (!data) return null;
return ( return (
<Credenza <Credenza
open={open} open={open}
onOpenChange={(b) => { onOpenChange={(b) => {
setModalOpen(b); setModalOpen(b);
}} }}
> >
<CredenzaContent className="max-h-[90vh] max-w-2xl"> <CredenzaContent className="max-h-[90vh] max-w-2xl">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle>Dettagli Ordine</CredenzaTitle> <CredenzaTitle>Dettagli Ordine</CredenzaTitle>
<CredenzaDescription> <CredenzaDescription>
Visualizza i dettagli e i pagamenti associati. Visualizza i dettagli e i pagamenti associati.
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5"> <CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
<p>Id Ordine: {data.ordine_id}</p> <p>Id Ordine: {data.ordine_id}</p>
<p> <p>
Data Ordine: Data Ordine:
{new Date(data.created_at).toLocaleDateString("it-IT", { {new Date(data.created_at).toLocaleDateString("it-IT", {
year: "numeric", year: "numeric",
month: "2-digit", month: "2-digit",
day: "2-digit", day: "2-digit",
})} })}
</p> </p>
<p>Tipologia: {data.type.toString()}</p> <p>Tipologia: {data.type.toString()}</p>
<p>Pack ID: {data.packid}</p> <p>Pack ID: {data.packid}</p>
<p className="flex items-center gap-2"> <p className="flex items-center gap-2">
Stato:{" "} Stato:{" "}
{data.isActive ? ( {data.isActive ? (
<span className="inline-flex items-center gap-1 text-green-500"> <span className="inline-flex items-center gap-1 text-green-500">
<CircleCheck className="size-5" /> <CircleCheck className="size-5" />
Attivo Attivo
</span> </span>
) : ( ) : (
<span className="inline-flex items-center gap-1"> <span className="inline-flex items-center gap-1">
<CircleSlash className="size-5" /> <CircleSlash className="size-5" />
Non Attivo Non Attivo
</span> </span>
)} )}
</p> </p>
{data.isActive && {data.isActive &&
data.pagamenti.find( data.pagamenti.find(
(p) => p.paymentstatus === PaymentStatusEnum.success, (p) => p.paymentstatus === PaymentStatusEnum.success,
) && ( ) && (
<div> <div>
<Link <Link
href={`/area-riservata/payment-recap/${data.ordine_id}`} href={`/area-riservata/payment-recap/${data.ordine_id}`}
target="_blank" target="_blank"
> >
<Button variant="outline" className="flex items-center gap-2"> <Button variant="outline" className="flex items-center gap-2">
Ricevuta di cortesia <ReceiptText /> Ricevuta di cortesia <ReceiptText />
</Button> </Button>
</Link> </Link>
</div> </div>
)} )}
<PaymentsTable data={data.pagamenti} isAdmin={isAdmin} isUserView /> <PaymentsTable data={data.pagamenti} isAdmin={isAdmin} isUserView />
</CredenzaBody> </CredenzaBody>
<CredenzaFooter <CredenzaFooter
className={cn( className={cn(
"flex w-full", "flex w-full",
isAdmin ? "sm:justify-between" : "sm:justify-end", isAdmin ? "sm:justify-between" : "sm:justify-end",
)} )}
> >
{isAdmin && ( {isAdmin && (
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="outline" aria-label="Modifica Status"> <Button variant="outline" aria-label="Modifica Status">
Modifica Status Modifica Status
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent> <DropdownMenuContent>
<DropdownMenuLabel>Status</DropdownMenuLabel> <DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuRadioGroup <DropdownMenuRadioGroup
value={data.isActive ? "true" : "false"} value={data.isActive ? "true" : "false"}
onValueChange={(v) => { onValueChange={(v) => {
update({ update({
ordineId, ordineId,
data: { isActive: v === "true" }, data: { isActive: v === "true" },
}); });
}} }}
> >
<DropdownMenuRadioItem value="true"> <DropdownMenuRadioItem value="true">
Attivo Attivo
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem value="false"> <DropdownMenuRadioItem value="false">
Non Attivo Non Attivo
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Confirm <Confirm
title="Elimina ordine" title="Elimina ordine"
description="Sei sicuro di voler eliminare l'ordine?" description="Sei sicuro di voler eliminare l'ordine?"
onConfirm={() => { onConfirm={() => {
remove({ ordineId }); remove({ ordineId });
}} }}
> >
<Button variant="destructive" aria-label="Elimina Ordine"> <Button variant="destructive" aria-label="Elimina Ordine">
<Trash2 className="size-6" /> <Trash2 className="size-6" />
Elimina Ordine Elimina Ordine
</Button> </Button>
</Confirm> </Confirm>
</div> </div>
)} )}
<CredenzaClose asChild> <CredenzaClose asChild>
<Button variant="ghost">Chiudi</Button> <Button variant="ghost">Chiudi</Button>
</CredenzaClose> </CredenzaClose>
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
); );
}; };

View file

@ -1,86 +1,86 @@
import { usePathname } from "next/navigation";
import { ARMinimizableLinks } from "~/components/navbar/mobile-nav";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Expand, Minimize2 } from "lucide-react";
import { motion } from "framer-motion";
import { getCookie, setCookie } from "cookies-next/client"; import { getCookie, setCookie } from "cookies-next/client";
import { useEffect, useState } from "react";
import { add } from "date-fns"; import { add } from "date-fns";
import { motion } from "framer-motion";
import { Expand, Minimize2 } from "lucide-react";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { ARMinimizableLinks } from "~/components/navbar/mobile-nav";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
export const Sidebar = ({ export const Sidebar = ({
isAdmin, isAdmin,
className, className,
}: { }: {
isAdmin: boolean; isAdmin: boolean;
className?: string; className?: string;
}) => { }) => {
const pathname = usePathname(); const pathname = usePathname();
const [minimized, setMinimized] = useState(false); const [minimized, setMinimized] = useState(false);
useEffect(() => { useEffect(() => {
const defaultOpen = getCookie("sidebar_minimized") === "true"; const defaultOpen = getCookie("sidebar_minimized") === "true";
setMinimized(defaultOpen); setMinimized(defaultOpen);
}, []); }, []);
const toggleSidebar = (v: boolean) => { const toggleSidebar = (v: boolean) => {
setMinimized(v); setMinimized(v);
setCookie("sidebar_minimized", v.toString(), { setCookie("sidebar_minimized", v.toString(), {
expires: add(new Date(), { days: 30 }), expires: add(new Date(), { days: 30 }),
}); });
}; };
return ( return (
<motion.nav <motion.nav
className={cn( className={cn(
"text-accent-foreground z-30 hidden h-auto w-48 shrink-0 space-y-4 border-r bg-white pt-3 md:block", "text-accent-foreground z-30 hidden h-auto w-48 shrink-0 space-y-4 border-r bg-white pt-3 md:block",
className, className,
minimized && "cursor-pointer", minimized && "cursor-pointer",
//defaultOpen && "w-[65px]", //defaultOpen && "w-[65px]",
)} )}
animate={{ width: minimized ? 65 : 192 }} animate={{ width: minimized ? 65 : 192 }}
onClick={ onClick={
minimized minimized
? (e) => { ? (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
toggleSidebar(false); toggleSidebar(false);
} }
: undefined : undefined
} }
aria-label="Sidebar Menu" aria-label="Sidebar Menu"
> >
<div <div
className={cn( className={cn(
"flex items-center justify-between px-7", "flex items-center justify-between px-7",
minimized && "justify-center px-0", minimized && "justify-center px-0",
)} )}
> >
{!minimized && ( {!minimized && (
<h4 className={cn("w-full text-xl font-semibold")}>Menu</h4> <h4 className={cn("w-full text-xl font-semibold")}>Menu</h4>
)} )}
<Button <Button
aria-label="Toggle Sidebar" aria-label="Toggle Sidebar"
variant="ghost" variant="ghost"
onClick={() => toggleSidebar(!minimized)} onClick={() => toggleSidebar(!minimized)}
className="px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0" className="px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
> >
{minimized ? ( {minimized ? (
<Expand className="size-6" /> <Expand className="size-6" />
) : ( ) : (
<Minimize2 className="size-6" /> <Minimize2 className="size-6" />
)} )}
<span className="sr-only">Toggle Menu</span> <span className="sr-only">Toggle Menu</span>
</Button> </Button>
</div> </div>
<div className={cn("px-4 text-lg", minimized && "px-0")}> <div className={cn("px-4 text-lg", minimized && "px-0")}>
<ARMinimizableLinks <ARMinimizableLinks
isAdmin={isAdmin} isAdmin={isAdmin}
setOpen={toggleSidebar} setOpen={toggleSidebar}
pathname={pathname} pathname={pathname}
minimized={minimized} minimized={minimized}
/> />
</div> </div>
</motion.nav> </motion.nav>
); );
}; };

View file

@ -1,115 +1,114 @@
import Link from "next/link";
import { useState } from "react";
import Cookies from "js-cookie"; import Cookies from "js-cookie";
import { useEffect, type MouseEvent } from "react";
import { X } from "lucide-react"; import { X } from "lucide-react";
import type { Banners } from "~/schemas/public/Banners"; import Link from "next/link";
import { cn } from "~/lib/utils"; import { type MouseEvent, useEffect, useState } from "react";
import { IconMatrix, type IconType } from "~/components/IconComponents"; import { IconMatrix, type IconType } from "~/components/IconComponents";
import { cn } from "~/lib/utils";
import type { Banners } from "~/schemas/public/Banners";
export const BannerFactory = (bannerData: Banners) => { export const BannerFactory = (bannerData: Banners) => {
const NEW_BANNER_KEY = `bannercookie_${bannerData.idbanner}`; const NEW_BANNER_KEY = `bannercookie_${bannerData.idbanner}`;
let bgColor = ""; let bgColor = "";
let accentColor = ""; let accentColor = "";
let closeHoverColor = ""; let closeHoverColor = "";
switch (bannerData.color) { switch (bannerData.color) {
case "blu": case "blu":
bgColor = "bg-blue-600"; bgColor = "bg-blue-600";
accentColor = "bg-blue-800"; accentColor = "bg-blue-800";
closeHoverColor = "hover:bg-blue-500"; closeHoverColor = "hover:bg-blue-500";
break; break;
case "rosso": case "rosso":
bgColor = "bg-red-600"; bgColor = "bg-red-600";
accentColor = "bg-red-800"; accentColor = "bg-red-800";
closeHoverColor = "hover:bg-red-500"; closeHoverColor = "hover:bg-red-500";
break; break;
case "verde": case "verde":
bgColor = "bg-green-600"; bgColor = "bg-green-600";
accentColor = "bg-green-800"; accentColor = "bg-green-800";
closeHoverColor = "hover:bg-green-500"; closeHoverColor = "hover:bg-green-500";
break; break;
case "indaco": case "indaco":
bgColor = "bg-indigo-600"; bgColor = "bg-indigo-600";
accentColor = "bg-indigo-800"; accentColor = "bg-indigo-800";
closeHoverColor = "hover:bg-indigo-500"; closeHoverColor = "hover:bg-indigo-500";
break; break;
case null: default:
default: bgColor = "bg-indigo-600";
bgColor = "bg-indigo-600"; accentColor = "bg-indigo-800";
accentColor = "bg-indigo-800"; closeHoverColor = "hover:bg-indigo-500";
closeHoverColor = "hover:bg-indigo-500"; break;
break; }
}
const NewBanner = () => { const NewBanner = () => {
const [isBannerToShow, setIsBannerToShow] = useState(false); const [isBannerToShow, setIsBannerToShow] = useState(false);
useEffect(() => { useEffect(() => {
if (bannerData.is_unskippable === false) { if (bannerData.is_unskippable === false) {
const cookievalue = Cookies.get(NEW_BANNER_KEY) !== "true"; const cookievalue = Cookies.get(NEW_BANNER_KEY) !== "true";
setIsBannerToShow(cookievalue); setIsBannerToShow(cookievalue);
} }
}, []); }, []);
const onClick = (e: MouseEvent<HTMLButtonElement>) => { const onClick = (e: MouseEvent<HTMLButtonElement>) => {
e.preventDefault(); e.preventDefault();
Cookies.set(NEW_BANNER_KEY, "true", { Cookies.set(NEW_BANNER_KEY, "true", {
expires: bannerData.hide_duration || 365, expires: bannerData.hide_duration || 365,
}); });
setIsBannerToShow(false); setIsBannerToShow(false);
}; };
if (!isBannerToShow && bannerData.is_unskippable === false) { if (!isBannerToShow && bannerData.is_unskippable === false) {
return null; return null;
} }
return ( return (
<div className={cn("bg-indigo-600", bgColor)}> <div className={cn("bg-indigo-600", bgColor)}>
<div className="mx-auto flex max-w-screen-xl items-start justify-between px-4 py-3 text-white sm:items-center md:px-8"> <div className="mx-auto flex max-w-screen-xl items-start justify-between px-4 py-3 text-white sm:items-center md:px-8">
<div className="flex flex-1 items-start justify-center gap-x-4 sm:items-center"> <div className="flex flex-1 items-start justify-center gap-x-4 sm:items-center">
<div <div
className={cn( className={cn(
"flex flex-none items-center justify-center rounded-full bg-indigo-800 p-1.5 px-4 text-sm font-medium", "flex flex-none items-center justify-center rounded-full bg-indigo-800 p-1.5 px-4 text-sm font-medium",
accentColor, accentColor,
)} )}
> >
{bannerData.titolo}{" "} {bannerData.titolo}{" "}
</div> </div>
<p className="flex gap-2 p-2 font-medium"> <p className="flex gap-2 p-2 font-medium">
<span> {bannerData.testo}</span> <span> {bannerData.testo}</span>
{bannerData.has_cta && {bannerData.has_cta &&
bannerData.cta_href && bannerData.cta_href &&
bannerData.cta_icon && ( bannerData.cta_icon && (
<Link <Link
href={bannerData.cta_href} href={bannerData.cta_href}
className="inline-flex items-center gap-x-1 font-semibold underline duration-150 hover:text-indigo-100" className="inline-flex items-center gap-x-1 font-semibold underline duration-150 hover:text-indigo-100"
> >
{bannerData.cta_text} {bannerData.cta_text}
<IconMatrix <IconMatrix
type={bannerData.cta_icon as IconType} type={bannerData.cta_icon as IconType}
className="size-4" className="size-4"
/> />
</Link> </Link>
)} )}
</p> </p>
</div> </div>
{bannerData.is_unskippable === false && ( {bannerData.is_unskippable === false && (
<button <button
className={cn( type="button"
"rounded-lg p-2 ring-offset-2 duration-150 hover:bg-indigo-500 focus:ring", className={cn(
closeHoverColor, "rounded-lg p-2 ring-offset-2 duration-150 hover:bg-indigo-500 focus:ring",
)} closeHoverColor,
onClick={onClick} )}
> onClick={onClick}
<X className="size-5" /> >
</button> <X className="size-5" />
)} </button>
</div> )}
</div> </div>
); </div>
}; );
return <NewBanner />; };
return <NewBanner />;
}; };

View file

@ -1,5 +1,5 @@
export default function BlurryDivider() { export default function BlurryDivider() {
return ( return (
<hr className="h-px border-t-0 bg-transparent bg-gradient-to-r from-transparent via-neutral-500 to-transparent opacity-80 dark:via-neutral-50 dark:opacity-100" /> <hr className="h-px border-t-0 bg-transparent bg-gradient-to-r from-transparent via-neutral-500 to-transparent opacity-80 dark:via-neutral-50 dark:opacity-100" />
); );
} }

View file

@ -1,191 +1,191 @@
"use client"; "use client";
import { differenceInDays, differenceInMinutes, format } from "date-fns";
import { Paperclip } from "lucide-react"; import { Paperclip } from "lucide-react";
import {
Credenza,
CredenzaTrigger,
CredenzaContent,
CredenzaBody,
CredenzaHeader,
CredenzaDescription,
CredenzaTitle,
CredenzaFooter,
CredenzaClose,
} from "~/components/custom_ui/credenza";
import { Button, buttonVariants } from "~/components/ui/button";
import { api } from "~/utils/api";
import { LoadingPage } from "~/components/loading";
import Link from "next/link"; import Link from "next/link";
import { cn } from "~/lib/utils";
import { ExtIcon } from "~/components/area-riservata/allegati"; import { ExtIcon } from "~/components/area-riservata/allegati";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import { LoadingPage } from "~/components/loading";
import { Button, buttonVariants } from "~/components/ui/button";
import { UploadComponent } from "~/components/upload_modal";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { Session } from "~/server/api/trpc"; import type { Session } from "~/server/api/trpc";
import type { ChatUserInfo } from "~/server/services/chat.service"; import type { ChatUserInfo } from "~/server/services/chat.service";
import { UploadComponent } from "~/components/upload_modal"; import { api } from "~/utils/api";
import { useTranslation } from "~/providers/I18nProvider";
import { differenceInDays, differenceInMinutes, format } from "date-fns";
type ChatAttachmentsProps = { type ChatAttachmentsProps = {
userData: Session; userData: Session;
chatUserData: ChatUserInfo; chatUserData: ChatUserInfo;
}; };
export const ChatAttachments = ({ export const ChatAttachments = ({
userData, userData,
chatUserData, chatUserData,
}: ChatAttachmentsProps) => { }: ChatAttachmentsProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { const {
data: attachments, data: attachments,
isLoading: attachmentsLoading, isLoading: attachmentsLoading,
refetch, refetch,
} = api.storage.getUserStorage.useQuery( } = api.storage.getUserStorage.useQuery(
{ userId: chatUserData.id }, { userId: chatUserData.id },
{ refetchOnMount: true }, { refetchOnMount: true },
); );
return ( return (
<Credenza onOpenChange={(o) => o && refetch()}> <Credenza onOpenChange={(o) => o && refetch()}>
<CredenzaTrigger asChild> <CredenzaTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="relative size-9" className="relative size-9"
aria-label="Allegati Chat" aria-label="Allegati Chat"
> >
{attachments && attachments.length > 0 && ( {attachments && attachments.length > 0 && (
<span className="absolute -top-1 right-0 rounded-full bg-blue-500 px-1 text-xs font-bold text-white"> <span className="absolute -top-1 right-0 rounded-full bg-blue-500 px-1 text-xs font-bold text-white">
{attachments.length} {attachments.length}
</span> </span>
)} )}
<Paperclip size={20} className="text-muted-foreground" /> <Paperclip size={20} className="text-muted-foreground" />
</Button> </Button>
</CredenzaTrigger> </CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]"> <CredenzaContent className="max-h-[90vh]">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle className="sr-only">Allegati</CredenzaTitle> <CredenzaTitle className="sr-only">Allegati</CredenzaTitle>
<CredenzaDescription className="sr-only"> <CredenzaDescription className="sr-only">
Chat Attachments Chat Attachments
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-3 overflow-auto pb-5"> <CredenzaBody className="max-h-[80vh] space-y-3 overflow-auto pb-5">
<UploadComponent <UploadComponent
isAdmin={userData.isAdmin} isAdmin={userData.isAdmin}
userId={chatUserData.id} userId={chatUserData.id}
/> />
<div className="h-auto max-h-[40rem] max-w-lg overflow-auto rounded-lg border-2 border-gray-300 py-4"> <div className="h-auto max-h-[40rem] max-w-lg overflow-auto rounded-lg border-2 border-gray-300 py-4">
{attachmentsLoading ? ( {attachmentsLoading ? (
<LoadingPage /> <LoadingPage />
) : ( ) : (
<> <>
{!attachments || attachments.length == 0 ? ( {!attachments || attachments.length === 0 ? (
<div className="flex h-full items-center justify-center"> <div className="flex h-full items-center justify-center">
<p className="text-center text-lg font-semibold text-gray-500"> <p className="text-center text-lg font-semibold text-gray-500">
{t.allegati.nessun_allegato} {t.allegati.nessun_allegato}
</p> </p>
</div> </div>
) : ( ) : (
<ul className="px-2"> <ul className="px-2">
<h3 className="px-3 text-lg font-semibold text-gray-700"> <h3 className="px-3 text-lg font-semibold text-gray-700">
{t.allegati.allegati_recenti} {t.allegati.allegati_recenti}
</h3> </h3>
{attachments?.map((file, idx) => { {attachments?.map((file, idx) => {
if (idx >= 3) return null; if (idx >= 3) return null;
return ( return (
<li <li
key={`${file.filename}-${idx}`} key={`${file.filename}-${idx}`}
className="flex items-center justify-between border-b px-3 py-1" className="flex items-center justify-between border-b px-3 py-1"
> >
<Link <Link
aria-label="Visualizza Allegato" aria-label="Visualizza Allegato"
href={`/area-riservata/allegato-view/${file.id}`} href={`/area-riservata/allegato-view/${file.id}`}
target="_blank" target="_blank"
className={cn( className={cn(
buttonVariants({ buttonVariants({
variant: "link", variant: "link",
}), }),
"flex h-8 items-center gap-2 truncate p-0 text-base", "flex h-8 items-center gap-2 truncate p-0 text-base",
)} )}
> >
{file.ext && <ExtIcon ext={file.ext} />} {file.ext && <ExtIcon ext={file.ext} />}
<span className="truncate">{file.filename}</span> <span className="truncate">{file.filename}</span>
</Link> </Link>
<span <span
className="text-muted-foreground text-xs" className="text-muted-foreground text-xs"
key={new Date().getTime()} key={new Date().toString()}
> >
{TimeSince(file.created_at)} {TimeSince(file.created_at)}
</span> </span>
</li> </li>
); );
})} })}
</ul> </ul>
)} )}
</> </>
)} )}
</div> </div>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter className="flex flex-row items-center justify-between gap-2"> <CredenzaFooter className="flex flex-row items-center justify-between gap-2">
<Link <Link
aria-label="Visualizza Allegati" aria-label="Visualizza Allegati"
href="/area-riservata/allegati" href="/area-riservata/allegati"
className={cn( className={cn(
buttonVariants({ buttonVariants({
variant: "outline", variant: "outline",
}), }),
"relative w-full", "relative w-full",
)} )}
> >
<Paperclip /> {t.allegati.i_tuoi_allegati} <Paperclip /> {t.allegati.i_tuoi_allegati}
{attachments && attachments.length > 0 && ( {attachments && attachments.length > 0 && (
<span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-red-600 text-xs text-white"> <span className="absolute -top-1 -right-1 flex size-5 items-center justify-center rounded-full bg-red-600 text-xs text-white">
{attachments.length} {attachments.length}
</span> </span>
)} )}
</Link> </Link>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button className="w-full">{t.chiudi}</Button> <Button className="w-full">{t.chiudi}</Button>
</CredenzaClose> </CredenzaClose>
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
); );
}; };
const TimeSince = (date: Date) => { const TimeSince = (date: Date) => {
const minsSince = Math.abs(differenceInMinutes(date, new Date())); const minsSince = Math.abs(differenceInMinutes(date, new Date()));
const daysSince = Math.abs(differenceInDays(date, new Date())); const daysSince = Math.abs(differenceInDays(date, new Date()));
const relevantMinsIncrements = [1, 3, 5, 10, 15, 30, 60, 120]; const relevantMinsIncrements = [1, 3, 5, 10, 15, 30, 60, 120];
if (daysSince === 0) { if (daysSince === 0) {
if (minsSince < 1) { if (minsSince < 1) {
return "ora"; return "ora";
} }
const closestToNow = relevantMinsIncrements.reduce((prev, curr) => { const closestToNow = relevantMinsIncrements.reduce((prev, curr) => {
return Math.abs(curr - minsSince) < Math.abs(prev - minsSince) return Math.abs(curr - minsSince) < Math.abs(prev - minsSince)
? curr ? curr
: prev; : prev;
}); });
if (closestToNow === 60) { if (closestToNow === 60) {
return "~1 ora fa"; return "~1 ora fa";
} }
if (closestToNow === 120 && minsSince < 121) { if (closestToNow === 120 && minsSince < 121) {
return "~2 ore fa"; return "~2 ore fa";
} }
return `~${closestToNow} min fa`; return `~${closestToNow} min fa`;
} }
if (daysSince === 1) { if (daysSince === 1) {
return "ieri"; return "ieri";
} }
if (daysSince < 30) { if (daysSince < 30) {
return `${daysSince} giorni fa`; return `${daysSince} giorni fa`;
} }
return format(date, "dd/MM/yyyy"); return format(date, "dd/MM/yyyy");
}; };

View file

@ -2,170 +2,170 @@
import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react"; import { SendHorizontal, SmilePlusIcon, ThumbsUp } from "lucide-react";
import { import {
useCallback, type ChangeEvent,
useEffect, useCallback,
useRef, useEffect,
useState, useRef,
type ChangeEvent, useState,
} from "react"; } from "react";
import { ChatAttachments } from "~/components/chat/chat-attachments";
import {
type AutosizeTextAreaRef,
AutosizeTextarea,
} from "~/components/custom_ui/autoResizeTextArea";
import {
EmojiPicker,
EmojiPickerContent,
EmojiPickerSearch,
} from "~/components/custom_ui/emoji-picker";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
AutosizeTextarea, Popover,
type AutosizeTextAreaRef, PopoverContent,
} from "~/components/custom_ui/autoResizeTextArea"; PopoverTrigger,
import { api } from "~/utils/api";
import { ChatAttachments } from "~/components/chat/chat-attachments";
import { useMediaQuery } from "~/hooks/use-media-query";
import { useThrottledIsTypingMutation } from "~/hooks/chatHooks";
import type { Session } from "~/server/api/trpc";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { ChatUserInfo } from "~/server/services/chat.service";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { import { useThrottledIsTypingMutation } from "~/hooks/chatHooks";
EmojiPickerContent, import { useMediaQuery } from "~/hooks/use-media-query";
EmojiPickerSearch,
EmojiPicker,
} from "~/components/custom_ui/emoji-picker";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { Session } from "~/server/api/trpc";
import type { ChatUserInfo } from "~/server/services/chat.service";
import { api } from "~/utils/api";
type ChatBottombarProps = { type ChatBottombarProps = {
chatId: ChatsChatid; chatId: ChatsChatid;
userData: Session; userData: Session;
chatUserData: ChatUserInfo; chatUserData: ChatUserInfo;
}; };
export default function ChatBottombar({ export default function ChatBottombar({
chatId, chatId,
userData, userData,
chatUserData, chatUserData,
}: ChatBottombarProps) { }: ChatBottombarProps) {
const { locale } = useTranslation(); const { locale } = useTranslation();
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<AutosizeTextAreaRef>(null); const inputRef = useRef<AutosizeTextAreaRef>(null);
const { mutate: addMutation } = api.messagesSSE.add.useMutation({}); const { mutate: addMutation } = api.messagesSSE.add.useMutation({});
const handleThumbsUp = useCallback(() => { const handleThumbsUp = useCallback(() => {
addMutation({ addMutation({
chatId, chatId,
message: "👍", message: "👍",
sender: userData.id, sender: userData.id,
}); });
setMessage(""); setMessage("");
}, [chatId, addMutation, userData]); }, [chatId, addMutation, userData]);
const handleSend = () => { const handleSend = () => {
if (message.length !== 0) { if (message.length !== 0) {
addMutation({ addMutation({
chatId, chatId,
message: message.trim(), message: message.trim(),
sender: userData.id, sender: userData.id,
}); });
setMessage(""); setMessage("");
if (inputRef.current) { if (inputRef.current) {
inputRef.current.textArea.focus(); inputRef.current.textArea.focus();
inputRef.current.textArea.style.height = "auto"; inputRef.current.textArea.style.height = "auto";
} }
} }
}; };
const isTypingMutation = useThrottledIsTypingMutation({ const isTypingMutation = useThrottledIsTypingMutation({
chatId, chatId,
username: userData.username, username: userData.username,
}); });
useEffect(() => { useEffect(() => {
// update isTyping state // update isTyping state
isTypingMutation(isFocused && message.trim().length > 0); isTypingMutation(isFocused && message.trim().length > 0);
}, [isFocused, message, isTypingMutation]); }, [isFocused, message, isTypingMutation]);
useEffect(() => { useEffect(() => {
if (inputRef.current) { if (inputRef.current) {
inputRef.current.textArea.focus(); inputRef.current.textArea.focus();
} }
}, []); }, []);
const [openEmoji, setOpenEmoji] = useState(false); const [openEmoji, setOpenEmoji] = useState(false);
const isMobile = useMediaQuery("(min-width: 426px)"); const isMobile = useMediaQuery("(min-width: 426px)");
return ( return (
<div className="flex min-h-[74px] w-full items-center justify-between gap-2 p-2"> <div className="flex min-h-[74px] w-full items-center justify-between gap-2 p-2">
<div className="flex"> <div className="flex">
<ChatAttachments userData={userData} chatUserData={chatUserData} /> <ChatAttachments userData={userData} chatUserData={chatUserData} />
</div> </div>
<div className="relative w-full"> <div className="relative w-full">
<AutosizeTextarea <AutosizeTextarea
autoComplete="off" autoComplete="off"
value={message} value={message}
minHeight={56} minHeight={56}
ref={inputRef} ref={inputRef}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.preventDefault(); e.preventDefault();
if (e.shiftKey) { if (e.shiftKey) {
setMessage((prev) => `${prev}\n`); setMessage((prev) => `${prev}\n`);
} else { } else {
handleSend(); handleSend();
} }
} }
}} }}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value); setMessage(e.target.value);
}} }}
name="message" name="message"
placeholder="Aa" placeholder="Aa"
className="bg-background flex h-14 min-h-14 w-full resize-none items-center overflow-hidden rounded-xl border pr-8 ring-0 outline-hidden hover:border-neutral-400 focus:border-neutral-600 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0" className="bg-background flex h-14 min-h-14 w-full resize-none items-center overflow-hidden rounded-xl border pr-8 ring-0 outline-hidden hover:border-neutral-400 focus:border-neutral-600 focus:ring-0 focus-visible:ring-0 focus-visible:ring-offset-0"
/> />
{isMobile && ( {isMobile && (
<div className="absolute top-0 right-2 flex h-full items-center"> <div className="absolute top-0 right-2 flex h-full items-center">
<Popover open={openEmoji} onOpenChange={setOpenEmoji}> <Popover open={openEmoji} onOpenChange={setOpenEmoji}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost"> <Button variant="ghost">
<SmilePlusIcon className="stroke-muted-foreground" /> <SmilePlusIcon className="stroke-muted-foreground" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" className="w-fit p-0"> <PopoverContent align="end" className="w-fit p-0">
<EmojiPicker <EmojiPicker
locale={locale as "it" | "en"} locale={locale as "it" | "en"}
className="h-84" className="h-84"
onEmojiSelect={({ emoji }) => { onEmojiSelect={({ emoji }) => {
setMessage(message + emoji); setMessage(message + emoji);
// if (inputRef.current) { // if (inputRef.current) {
// inputRef.current.textArea.focus(); // inputRef.current.textArea.focus();
// } // }
}} }}
> >
<EmojiPickerSearch <EmojiPickerSearch
placeholder={locale == "en" ? "Search..." : "Cerca..."} placeholder={locale === "en" ? "Search..." : "Cerca..."}
/> />
<EmojiPickerContent /> <EmojiPickerContent />
</EmojiPicker> </EmojiPicker>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
</div> </div>
)} )}
</div> </div>
<Button <Button
aria-label="Send Message" aria-label="Send Message"
variant="ghost" variant="ghost"
className="shrink-0" className="shrink-0"
onClick={ onClick={
message.length != 0 ? () => handleSend() : () => handleThumbsUp() message.length !== 0 ? () => handleSend() : () => handleThumbsUp()
} }
> >
{message.length != 0 ? ( {message.length !== 0 ? (
<SendHorizontal size={20} className="text-muted-foreground" /> <SendHorizontal size={20} className="text-muted-foreground" />
) : ( ) : (
<ThumbsUp size={20} className="text-muted-foreground" /> <ThumbsUp size={20} className="text-muted-foreground" />
)} )}
</Button> </Button>
</div> </div>
); );
} }

View file

@ -1,202 +1,199 @@
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority"; import { cva, type VariantProps } from "class-variance-authority";
import MessageLoading from "~/components/chat/message-loading";
import { cn } from "~/lib/utils";
import { Button, type ButtonProps } from "~/components/ui/button";
import { Check, CheckCheck } from "lucide-react"; import { Check, CheckCheck } from "lucide-react";
import * as React from "react";
import MessageLoading from "~/components/chat/message-loading";
import { Button, type ButtonProps } from "~/components/ui/button";
import { cn } from "~/lib/utils";
// ChatBubble // ChatBubble
const chatBubbleVariant = cva( const chatBubbleVariant = cva(
"flex gap-2 max-w-[60%] items-end relative group", "flex gap-2 max-w-[60%] items-end relative group",
{ {
variants: { variants: {
variant: { variant: {
received: "self-start", received: "self-start",
sent: "self-end flex-row-reverse", sent: "self-end flex-row-reverse",
}, },
layout: { layout: {
default: "", default: "",
ai: "max-w-full w-full items-center", ai: "max-w-full w-full items-center",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "received", variant: "received",
layout: "default", layout: "default",
}, },
}, },
); );
interface ChatBubbleProps interface ChatBubbleProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleVariant> {} VariantProps<typeof chatBubbleVariant> {}
const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>( const ChatBubble = React.forwardRef<HTMLDivElement, ChatBubbleProps>(
({ className, variant, layout, children, ...props }, ref) => ( ({ className, variant, layout, children, ...props }, ref) => (
<div <div
className={cn( className={cn(
chatBubbleVariant({ variant, layout, className }), chatBubbleVariant({ variant, layout, className }),
"group relative", "group relative",
)} )}
ref={ref} ref={ref}
{...props} {...props}
> >
{React.Children.map(children, (child) => {React.Children.map(children, (child) =>
React.isValidElement(child) && typeof child.type !== "string" React.isValidElement(child) && typeof child.type !== "string"
? React.cloneElement(child, { ? React.cloneElement(child, {
variant, variant,
layout, layout,
} as Partial<React.ComponentProps<typeof child.type>>) } as Partial<React.ComponentProps<typeof child.type>>)
: child, : child,
)} )}
</div> </div>
), ),
); );
ChatBubble.displayName = "ChatBubble"; ChatBubble.displayName = "ChatBubble";
type ChatBubbleReadStatusProps = { type ChatBubbleReadStatusProps = {
isread: boolean; isread: boolean;
}; };
export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => { export const ChatBubbleReadStatus = ({ isread }: ChatBubbleReadStatusProps) => {
if (isread) { if (isread) {
return <CheckCheck className="mt-2 size-4 text-green-500" />; return <CheckCheck className="mt-2 size-4 text-green-500" />;
} else { } else {
return <Check className="text-muted-foreground mt-2 size-4" />; return <Check className="text-muted-foreground mt-2 size-4" />;
} }
return null;
}; };
// ChatBubbleMessage // ChatBubbleMessage
const chatBubbleMessageVariants = cva("p-2", { const chatBubbleMessageVariants = cva("p-2", {
variants: { variants: {
variant: { variant: {
received: received:
"bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg", "bg-secondary text-secondary-foreground rounded-r-lg rounded-tl-lg",
sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg", sent: "bg-primary text-primary-foreground rounded-l-lg rounded-tr-lg",
}, },
layout: { layout: {
default: "", default: "",
ai: "border-t w-full rounded-none bg-transparent", ai: "border-t w-full rounded-none bg-transparent",
}, },
}, },
defaultVariants: { defaultVariants: {
variant: "received", variant: "received",
layout: "default", layout: "default",
}, },
}); });
interface ChatBubbleMessageProps interface ChatBubbleMessageProps
extends React.HTMLAttributes<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof chatBubbleMessageVariants> { VariantProps<typeof chatBubbleMessageVariants> {
isLoading?: boolean; isLoading?: boolean;
} }
const ChatBubbleMessage = React.forwardRef< const ChatBubbleMessage = React.forwardRef<
HTMLDivElement, HTMLDivElement,
ChatBubbleMessageProps ChatBubbleMessageProps
>( >(
( (
{ className, variant, layout, isLoading = false, children, ...props }, { className, variant, layout, isLoading = false, children, ...props },
ref, ref,
) => ( ) => (
<div <div
className={cn( className={cn(
chatBubbleMessageVariants({ variant, layout, className }), chatBubbleMessageVariants({ variant, layout, className }),
"max-w-full break-words whitespace-pre-wrap", "max-w-full break-words whitespace-pre-wrap",
)} )}
ref={ref} ref={ref}
{...props} {...props}
> >
{isLoading ? ( {isLoading ? (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<MessageLoading /> <MessageLoading />
</div> </div>
) : ( ) : (
children children
)} )}
</div> </div>
), ),
); );
ChatBubbleMessage.displayName = "ChatBubbleMessage"; ChatBubbleMessage.displayName = "ChatBubbleMessage";
// ChatBubbleTimestamp // ChatBubbleTimestamp
interface ChatBubbleTimestampProps interface ChatBubbleTimestampProps
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {
timestamp: string; timestamp: string;
} }
const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({ const ChatBubbleTimestamp: React.FC<ChatBubbleTimestampProps> = ({
timestamp, timestamp,
className, className,
...props ...props
}) => ( }) => (
<div className={cn("mt-2 text-right text-xs", className)} {...props}> <div className={cn("mt-2 text-right text-xs", className)} {...props}>
{timestamp} {timestamp}
</div> </div>
); );
// ChatBubbleAction // ChatBubbleAction
type ChatBubbleActionProps = ButtonProps & { type ChatBubbleActionProps = ButtonProps & {
icon: React.ReactNode; icon: React.ReactNode;
}; };
const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({ const ChatBubbleAction: React.FC<ChatBubbleActionProps> = ({
icon, icon,
onClick, onClick,
className, className,
variant = "ghost", variant = "ghost",
size = "icon", size = "icon",
...props ...props
}) => ( }) => (
<Button <Button
aria-label="Chat Bubble Action" aria-label="Chat Bubble Action"
variant={variant} variant={variant}
size={size} size={size}
className={className} className={className}
onClick={onClick} onClick={onClick}
{...props} {...props}
> >
{icon} {icon}
</Button> </Button>
); );
interface ChatBubbleActionWrapperProps interface ChatBubbleActionWrapperProps
extends React.HTMLAttributes<HTMLDivElement> { extends React.HTMLAttributes<HTMLDivElement> {
variant?: "sent" | "received"; variant?: "sent" | "received";
className?: string; className?: string;
show: boolean; show: boolean;
} }
const ChatBubbleActionWrapper = React.forwardRef< const ChatBubbleActionWrapper = React.forwardRef<
HTMLDivElement, HTMLDivElement,
ChatBubbleActionWrapperProps ChatBubbleActionWrapperProps
>(({ variant, className, show, children, ...props }, ref) => ( >(({ variant, className, show, children, ...props }, ref) => (
<> <>
{show && ( {show && (
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"absolute top-1/2 flex -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:opacity-100", "absolute top-1/2 flex -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:opacity-100",
variant === "sent" variant === "sent"
? "-left-1 -translate-x-full flex-row-reverse" ? "-left-1 -translate-x-full flex-row-reverse"
: "-right-1 translate-x-full", : "-right-1 translate-x-full",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
</div> </div>
)} )}
</> </>
)); ));
ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper"; ChatBubbleActionWrapper.displayName = "ChatBubbleActionWrapper";
export { export {
ChatBubble, ChatBubble,
ChatBubbleMessage, ChatBubbleMessage,
ChatBubbleTimestamp, ChatBubbleTimestamp,
ChatBubbleAction, ChatBubbleAction,
ChatBubbleActionWrapper, ChatBubbleActionWrapper,
}; };

View file

@ -1,439 +1,440 @@
"use client"; "use client";
import { cn } from "~/lib/utils";
import {
useCallback,
useEffect,
useRef,
useState,
type FormEvent,
} from "react";
import { api } from "~/utils/api";
import toast from "react-hot-toast";
import Input from "~/components/custom_ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Pencil, Trash2 } from "lucide-react";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { Pencil, Trash2 } from "lucide-react";
import { import {
ChatBubble, type FormEvent,
ChatBubbleAction, useCallback,
ChatBubbleActionWrapper, useEffect,
ChatBubbleMessage, useRef,
ChatBubbleReadStatus, useState,
ChatBubbleTimestamp, } from "react";
import toast from "react-hot-toast";
import {
ChatBubble,
ChatBubbleAction,
ChatBubbleActionWrapper,
ChatBubbleMessage,
ChatBubbleReadStatus,
ChatBubbleTimestamp,
} from "~/components/chat/chat-bubble"; } from "~/components/chat/chat-bubble";
import { ChatMessageList } from "~/components/chat/chat-message-list"; import { ChatMessageList } from "~/components/chat/chat-message-list";
import { import Input from "~/components/custom_ui/input";
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { useTranslation } from "~/providers/I18nProvider";
import type { ChatMessage } from "~/server/services/messages.service";
import type { UsersId } from "~/schemas/public/Users";
import type { WhoIsTyping } from "~/server/sse";
import type { Session } from "~/server/api/trpc";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { LiveChat } from "~/hooks/chatHooks";
import LoadingButton from "~/components/custom_ui/loading-button"; import LoadingButton from "~/components/custom_ui/loading-button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import { getUserColor, UserAvatar } from "~/components/user_avatar"; import { getUserColor, UserAvatar } from "~/components/user_avatar";
import type { LiveChat } from "~/hooks/chatHooks";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { MessagesMessageid } from "~/schemas/public/Messages";
import type { UsersId } from "~/schemas/public/Users";
import type { Session } from "~/server/api/trpc";
import type { ChatMessage } from "~/server/services/messages.service";
import type { WhoIsTyping } from "~/server/sse";
import { api } from "~/utils/api";
type ChatListProps = { type ChatListProps = {
chatId: ChatsChatid; chatId: ChatsChatid;
messages: ChatMessage[]; messages: ChatMessage[];
userData: Session; userData: Session;
query: LiveChat["query"]; query: LiveChat["query"];
typingStatus: WhoIsTyping[]; typingStatus: WhoIsTyping[];
}; };
export const ChatList = ({ export const ChatList = ({
chatId, chatId,
messages, messages,
userData, userData,
query, query,
typingStatus, typingStatus,
}: ChatListProps) => { }: ChatListProps) => {
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const [editModalOpen, setEditModalOpen] = useState(false); const [editModalOpen, setEditModalOpen] = useState(false);
const [msgSelected, setMsgSelected] = useState<ChatMessage | null>(null); const [msgSelected, setMsgSelected] = useState<ChatMessage | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const handleEditOpen = (status: boolean) => { const handleEditOpen = (status: boolean) => {
setEditModalOpen(status); setEditModalOpen(status);
}; };
const handleDeleteOpen = (status: boolean) => { const handleDeleteOpen = (status: boolean) => {
setDeleteModalOpen(status); setDeleteModalOpen(status);
}; };
const { mutate: deleteMutation } = api.messagesSSE.delete.useMutation({ const { mutate: deleteMutation } = api.messagesSSE.delete.useMutation({
onMutate: () => { onMutate: () => {
toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" }); toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" });
}, },
onSuccess: () => { onSuccess: () => {
toast("Message deleted", { icon: "🗑️", id: "deleteMessage" }); toast("Message deleted", { icon: "🗑️", id: "deleteMessage" });
}, },
onError: () => { onError: () => {
toast.error("Error deleting message", { id: "deleteMessage" }); toast.error("Error deleting message", { id: "deleteMessage" });
}, },
}); });
const { mutate: editMutation } = api.messagesSSE.edit.useMutation({ const { mutate: editMutation } = api.messagesSSE.edit.useMutation({
onMutate: () => { onMutate: () => {
toast("Editing message...", { icon: "📝", id: "editMessage" }); toast("Editing message...", { icon: "📝", id: "editMessage" });
}, },
onSuccess: () => { onSuccess: () => {
toast("Message edited", { icon: "📝", id: "editMessage" }); toast("Message edited", { icon: "📝", id: "editMessage" });
}, },
onError: () => { onError: () => {
toast.error("Error editing message", { id: "editMessage" }); toast.error("Error editing message", { id: "editMessage" });
}, },
}); });
const handleEditSubmit = useCallback( const handleEditSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => { (e: FormEvent<HTMLFormElement>) => {
e.preventDefault(); e.preventDefault();
const form = e.currentTarget; const form = e.currentTarget;
const formData = new FormData(form); const formData = new FormData(form);
if (!formData.get("message-edit") || !formData.get("message-id")) { if (!formData.get("message-edit") || !formData.get("message-id")) {
return; return;
} }
editMutation({ editMutation({
chatId, chatId,
messageId: formData.get("message-id") as MessagesMessageid, messageId: formData.get("message-id") as MessagesMessageid,
message: formData.get("message-edit") as string, message: formData.get("message-edit") as string,
}); });
setEditModalOpen(false); setEditModalOpen(false);
}, },
[editMutation], [editMutation],
); );
const [locked, setLocked] = useState(false); const [locked, setLocked] = useState(false);
useEffect(() => { useEffect(() => {
if ( if (
messagesContainerRef.current && messagesContainerRef.current &&
!locked && !locked &&
!query.isLoading && !query.isLoading &&
!query.isFetching !query.isFetching
) { ) {
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight; messagesContainerRef.current.scrollHeight;
} }
setLocked(false); setLocked(false);
}, [messages, typingStatus]); }, [messages, typingStatus]);
return ( return (
<div className="flex h-full w-full flex-col overflow-y-auto"> <div className="flex h-full w-full flex-col overflow-y-auto">
<ChatMessageList ref={messagesContainerRef}> <ChatMessageList ref={messagesContainerRef}>
<AnimatePresence key="chat-messages"> <AnimatePresence key="chat-messages">
{query.hasNextPage && ( {query.hasNextPage && (
<LoadingButton <LoadingButton
aria-label="Load More Messages" aria-label="Load More Messages"
loading={query.isFetchingNextPage} loading={query.isFetchingNextPage}
variant="secondary" variant="secondary"
key="load-more" key="load-more"
disabled={!query.hasNextPage || query.isFetchingNextPage} disabled={!query.hasNextPage || query.isFetchingNextPage}
onClick={async () => { onClick={async () => {
setLocked(true); setLocked(true);
await query.fetchNextPage(); await query.fetchNextPage();
}} }}
className="mb-3" className="mb-3"
> >
{query.isFetchingNextPage {query.isFetchingNextPage
? "Caricamento..." ? "Caricamento..."
: "Carica messaggi precedenti"} : "Carica messaggi precedenti"}
</LoadingButton> </LoadingButton>
)} )}
{messages.map((message, index) => { {messages.map((message, index) => {
const variant = getMessageVariant(message.sender, userData.id); const variant = getMessageVariant(message.sender, userData.id);
const isNextSameSender = const isNextSameSender =
index + 1 < messages.length && index + 1 < messages.length &&
messages[index + 1]?.sender == message.sender; messages[index + 1]?.sender === message.sender;
const isPreviousSameSender = const isPreviousSameSender =
index - 1 >= 0 && messages[index - 1]?.sender == message.sender; index - 1 >= 0 && messages[index - 1]?.sender === message.sender;
const isPrevSameDay = const isPrevSameDay =
index - 1 >= 0 && index - 1 >= 0 &&
new Date(messages[index - 1]!.time).toDateString() === // biome-ignore lint/style/noNonNullAssertion: <non lo so>
new Date(message.time).toDateString(); new Date(messages[index - 1]!.time).toDateString() ===
new Date(message.time).toDateString();
return ( return (
<motion.div <motion.div
key={message.messageid} key={message.messageid}
layout layout
initial={{ opacity: 0, scale: 1, y: 50, x: 0 }} initial={{ opacity: 0, scale: 1, y: 50, x: 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }} animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 1, y: 1, x: 0 }} exit={{ opacity: 0, scale: 1, y: 1, x: 0 }}
transition={{ transition={{
opacity: { duration: 0.1 }, opacity: { duration: 0.1 },
layout: { layout: {
type: "spring", type: "spring",
bounce: 0.3, bounce: 0.3,
duration: 0.3, duration: 0.3,
}, },
}} }}
style={{ originX: 0.5, originY: 0.5 }} style={{ originX: 0.5, originY: 0.5 }}
className={cn( className={cn(
"flex flex-col gap-2 pb-4", "flex flex-col gap-2 pb-4",
isNextSameSender && "pb-0.5", isNextSameSender && "pb-0.5",
)} )}
> >
{!isPrevSameDay && ( {!isPrevSameDay && (
<span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-xs text-muted-foreground"> <span className="mx-auto rounded-md bg-muted px-2 py-1 text-center text-xs text-muted-foreground">
{new Date(message.time).toLocaleDateString("it", { {new Date(message.time).toLocaleDateString("it", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "2-digit", year: "2-digit",
})} })}
</span> </span>
)} )}
<ChatBubble variant={variant}> <ChatBubble variant={variant}>
{isNextSameSender ? ( {isNextSameSender ? (
<div className="size-6"></div> <div className="size-6"></div>
) : ( ) : (
<UserAvatar userId={message.sender} className="size-6" /> <UserAvatar userId={message.sender} className="size-6" />
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{!isPreviousSameSender && ( {!isPreviousSameSender && (
<p <p
className={cn( className={cn(
"text-sm font-semibold", "text-sm font-semibold",
variant === "sent" && "text-right", variant === "sent" && "text-right",
)} )}
style={{ style={{
color: getUserColor(message.sender), color: getUserColor(message.sender),
}} }}
> >
{message.sender_nome} {message.sender_nome}
</p> </p>
)} )}
<ChatBubbleMessage <ChatBubbleMessage
isLoading={false} isLoading={false}
className={cn(isNextSameSender && "rounded-lg")} className={cn(isNextSameSender && "rounded-lg")}
> >
{message.message} {message.message}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{userData.id == message.sender && ( {userData.id === message.sender && (
<ChatBubbleReadStatus isread={message.isread} /> <ChatBubbleReadStatus isread={message.isread} />
)} )}
{message.time && ( {message.time && (
<ChatBubbleTimestamp <ChatBubbleTimestamp
timestamp={new Date(message.time).toLocaleString( timestamp={new Date(message.time).toLocaleString(
"it", "it",
{ {
day: "2-digit", day: "2-digit",
month: "short", month: "short",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
}, },
)} )}
/> />
)} )}
</div> </div>
</ChatBubbleMessage> </ChatBubbleMessage>
</div> </div>
<ChatBubbleActionWrapper show={userData.isAdmin}> <ChatBubbleActionWrapper show={userData.isAdmin}>
<ChatBubbleAction <ChatBubbleAction
className="size-7 hover:bg-red-50 hover:text-red-500" className="size-7 hover:bg-red-50 hover:text-red-500"
key={"delete"} key={"delete"}
icon={<Trash2 className="size-4" />} icon={<Trash2 className="size-4" />}
onClick={() => { onClick={() => {
setMsgSelected(message); setMsgSelected(message);
handleDeleteOpen(true); handleDeleteOpen(true);
}} }}
/> />
<ChatBubbleAction <ChatBubbleAction
className="size-7 hover:bg-blue-50 hover:text-blue-500" className="size-7 hover:bg-blue-50 hover:text-blue-500"
key={"edit"} key={"edit"}
icon={<Pencil className="size-4" />} icon={<Pencil className="size-4" />}
onClick={() => { onClick={() => {
setMsgSelected(message); setMsgSelected(message);
handleEditOpen(true); handleEditOpen(true);
}} }}
/> />
</ChatBubbleActionWrapper> </ChatBubbleActionWrapper>
</ChatBubble> </ChatBubble>
</motion.div> </motion.div>
); );
})} })}
<Typers <Typers
currentTypers={typingStatus} currentTypers={typingStatus}
messageLenght={messages.length} messageLenght={messages.length}
userId={userData.id} userId={userData.id}
/> />
</AnimatePresence> </AnimatePresence>
</ChatMessageList> </ChatMessageList>
<EditMessageDialog <EditMessageDialog
open={editModalOpen} open={editModalOpen}
onOpenChange={handleEditOpen} onOpenChange={handleEditOpen}
msgEdit={msgSelected} msgEdit={msgSelected}
handleEditSubmit={handleEditSubmit} handleEditSubmit={handleEditSubmit}
/> />
<DeleteMessageDialog <DeleteMessageDialog
open={deleteModalOpen} open={deleteModalOpen}
onOpenChange={handleDeleteOpen} onOpenChange={handleDeleteOpen}
msgDelete={msgSelected} msgDelete={msgSelected}
handleDeleteSubmit={(messageId) => { handleDeleteSubmit={(messageId) => {
deleteMutation({ chatId, messageId }); deleteMutation({ chatId, messageId });
}} }}
/> />
</div> </div>
); );
}; };
const Typers = ({ const Typers = ({
currentTypers, currentTypers,
messageLenght, messageLenght,
userId, userId,
}: { }: {
currentTypers: WhoIsTyping[]; currentTypers: WhoIsTyping[];
messageLenght: number; messageLenght: number;
userId: UsersId; userId: UsersId;
}) => { }) => {
return ( return (
<> <>
{currentTypers.map((typer, index) => { {currentTypers.map((typer, index) => {
if (typer.userId === userId) return null; if (typer.userId === userId) return null;
const variant = "received"; const variant = "received";
return ( return (
<motion.div <motion.div
key={"typers" + messageLenght + index} key={`typers${messageLenght}${index}`}
layout layout
initial={{ opacity: 0, scale: 1, y: 50, x: 0 }} initial={{ opacity: 0, scale: 1, y: 50, x: 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }} animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 1, y: 1, x: 0 }} exit={{ opacity: 0, scale: 1, y: 1, x: 0 }}
transition={{ transition={{
opacity: { duration: 0.1 }, opacity: { duration: 0.1 },
layout: { layout: {
type: "spring", type: "spring",
bounce: 0.3, bounce: 0.3,
duration: 0.3, duration: 0.3,
}, },
}} }}
style={{ originX: 0.5, originY: 0.5 }} style={{ originX: 0.5, originY: 0.5 }}
className="flex flex-col gap-2 py-2" className="flex flex-col gap-2 py-2"
> >
<ChatBubble variant={variant}> <ChatBubble variant={variant}>
<UserAvatar userId={typer.userId} className="size-6" /> <UserAvatar userId={typer.userId} className="size-6" />
<ChatBubbleMessage isLoading={true}></ChatBubbleMessage> <ChatBubbleMessage isLoading={true}></ChatBubbleMessage>
</ChatBubble> </ChatBubble>
</motion.div> </motion.div>
); );
})} })}
</> </>
); );
}; };
const EditMessageDialog = ({ const EditMessageDialog = ({
open, open,
onOpenChange, onOpenChange,
msgEdit, msgEdit,
handleEditSubmit, handleEditSubmit,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (status: boolean) => void; onOpenChange: (status: boolean) => void;
msgEdit: ChatMessage | null; msgEdit: ChatMessage | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void; handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle> <DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription> <DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader> </DialogHeader>
{msgEdit && ( {msgEdit && (
<form onSubmit={handleEditSubmit} method="post"> <form onSubmit={handleEditSubmit} method="post">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<label htmlFor="id" className="sr-only"> <label htmlFor="id" className="sr-only">
ID ID
</label> </label>
<Input <Input
id="id" id="id"
type="text" type="text"
className="hidden" className="hidden"
name="message-id" name="message-id"
value={msgEdit.messageid} value={msgEdit.messageid}
readOnly readOnly
/> />
<label htmlFor="message-edit" className="sr-only"> <label htmlFor="message-edit" className="sr-only">
Modifica messaggio Modifica messaggio
</label> </label>
<Input <Input
id="message-edit" id="message-edit"
type="text" type="text"
placeholder="Modifica messaggio" placeholder="Modifica messaggio"
className="mt-0" className="mt-0"
name="message-edit" name="message-edit"
defaultValue={msgEdit.message || undefined} defaultValue={msgEdit.message || undefined}
/> />
<Button type="submit">{t.salva}</Button> <Button type="submit">{t.salva}</Button>
</div> </div>
</form> </form>
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
const DeleteMessageDialog = ({ const DeleteMessageDialog = ({
open, open,
onOpenChange, onOpenChange,
msgDelete, msgDelete,
handleDeleteSubmit, handleDeleteSubmit,
}: { }: {
open: boolean; open: boolean;
onOpenChange: (status: boolean) => void; onOpenChange: (status: boolean) => void;
msgDelete: ChatMessage | null; msgDelete: ChatMessage | null;
handleDeleteSubmit: (msgId: MessagesMessageid) => void; handleDeleteSubmit: (msgId: MessagesMessageid) => void;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
{msgDelete && ( {msgDelete && (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Vuoi eliminare il messaggio?</AlertDialogTitle> <AlertDialogTitle>Vuoi eliminare il messaggio?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Questa azione non può essere annullata. Questa azione non può essere annullata.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel> <AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
aria-label="Delete Message" aria-label="Delete Message"
onClick={() => handleDeleteSubmit(msgDelete.messageid)} onClick={() => handleDeleteSubmit(msgDelete.messageid)}
> >
{t.procedi} {t.procedi}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
</> </>
); );
}; };
const getMessageVariant = (messageName: string, selectedUserName: string) => const getMessageVariant = (messageName: string, selectedUserName: string) =>
messageName !== selectedUserName ? "received" : "sent"; messageName !== selectedUserName ? "received" : "sent";

View file

@ -1,447 +1,447 @@
import { Search, SquarePen } from "lucide-react"; import { Search, SquarePen } from "lucide-react";
import { cn } from "~/lib/utils"; import Link from "next/link";
import { Button, buttonVariants } from "~/components/ui/button"; import { useRouter } from "next/router";
import { useMediaQuery } from "~/hooks/use-media-query";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import Input from "~/components/custom_ui/input"; import Input from "~/components/custom_ui/input";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/components/ui/command";
import { api } from "~/utils/api";
import { useRouter } from "next/router";
import type { ChatsChatid } from "~/schemas/public/Chats";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import Link from "next/link";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Etichetta, EtichetteModal } from "~/components/etichette"; import { Etichetta, EtichetteModal } from "~/components/etichette";
import type { NonNullableUser } from "~/server/controllers/user.controller";
import { LoadingPage } from "~/components/loading"; import { LoadingPage } from "~/components/loading";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "~/components/ui/alert-dialog";
import { Button, buttonVariants } from "~/components/ui/button";
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "~/components/ui/command";
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "~/components/ui/context-menu";
import { UserAvatar } from "~/components/user_avatar"; import { UserAvatar } from "~/components/user_avatar";
import { useMediaQuery } from "~/hooks/use-media-query";
import { cn } from "~/lib/utils";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { ActiveChatsType } from "~/server/controllers/chat.controller"; import type { ActiveChatsType } from "~/server/controllers/chat.controller";
import type { NonNullableUser } from "~/server/controllers/user.controller";
import { api } from "~/utils/api";
type LastMessage = { type LastMessage = {
message: string | null; message: string | null;
time: Date; time: Date;
isLastSender: boolean; isLastSender: boolean;
}; };
export const ChatSidebar = ({ chatId }: { chatId: ChatsChatid | null }) => { export const ChatSidebar = ({ chatId }: { chatId: ChatsChatid | null }) => {
const { data: activeChats, isLoading: loadingActiveChats } = const { data: activeChats, isLoading: loadingActiveChats } =
api.chat.getActiveChats.useQuery(undefined, { api.chat.getActiveChats.useQuery(undefined, {
refetchInterval: 5000, // Refetch every 5 seconds, Good enough for a chat sidebar refetchInterval: 5000, // Refetch every 5 seconds, Good enough for a chat sidebar
refetchOnWindowFocus: true, refetchOnWindowFocus: true,
}); });
const { data: inactiveUsers, isLoading: loadingInactiveUsers } = const { data: inactiveUsers, isLoading: loadingInactiveUsers } =
api.users.getActiveUsersWithoutChat.useQuery(); api.users.getActiveUsersWithoutChat.useQuery();
const router = useRouter(); const router = useRouter();
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
const [selectedChat, setSelectedChat] = useState<ChatsChatid | null>(null); const [selectedChat, setSelectedChat] = useState<ChatsChatid | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false); const [addDialogOpen, setAddDialogOpen] = useState(false);
const [searchDialogOpen, setSearchDialogOpen] = useState(false); const [searchDialogOpen, setSearchDialogOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false); const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [editEtichettaModalOpen, setEditEtichettaModalOpen] = useState(false); const [editEtichettaModalOpen, setEditEtichettaModalOpen] = useState(false);
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: deletechat } = api.chat.deleteChat.useMutation({ const { mutate: deletechat } = api.chat.deleteChat.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.chat.getActiveChats.invalidate(); await utils.chat.getActiveChats.invalidate();
await utils.users.getActiveUsersWithoutChat.invalidate(); await utils.users.getActiveUsersWithoutChat.invalidate();
}, },
}); });
const getVariant = (lastmessage: LastMessage | null) => { const getVariant = (lastmessage: LastMessage | null) => {
if (lastmessage) { if (lastmessage) {
return lastmessage.isLastSender ? "secondary" : "grey"; return lastmessage.isLastSender ? "secondary" : "grey";
} }
return "grey"; return "grey";
}; };
const getLastMessage = (lastmessage: LastMessage | null) => { const getLastMessage = (lastmessage: LastMessage | null) => {
if (lastmessage) { if (lastmessage) {
return `${lastmessage.isLastSender ? "Tu: " : ""}${lastmessage.message}`; return `${lastmessage.isLastSender ? "Tu: " : ""}${lastmessage.message}`;
} }
return ""; return "";
}; };
const handleChatSelection = useCallback( const handleChatSelection = useCallback(
async (new_chatid: ChatsChatid) => { async (new_chatid: ChatsChatid) => {
if (chatId === new_chatid) return; if (chatId === new_chatid) return;
await router.push(`/area-riservata/admin/chats/${new_chatid}`); await router.push(`/area-riservata/admin/chats/${new_chatid}`);
setSearchDialogOpen(false); setSearchDialogOpen(false);
}, },
[chatId, router], [chatId, router],
); );
if (loadingActiveChats || loadingInactiveUsers) return <LoadingPage />; if (loadingActiveChats || loadingInactiveUsers) return <LoadingPage />;
if (!activeChats || !inactiveUsers) return <div>Errore caricamento</div>; if (!activeChats || !inactiveUsers) return <div>Errore caricamento</div>;
return ( return (
<div className="group relative flex h-full flex-col gap-4 py-2 data-[collapsed=true]:p-2"> <div className="group relative flex h-full flex-col gap-4 py-2 data-[collapsed=true]:p-2">
<div className="mx-2 flex flex-col items-center justify-between gap-2 sm:flex-row sm:gap-0"> <div className="mx-2 flex flex-col items-center justify-between gap-2 sm:flex-row sm:gap-0">
{isDesktop ? ( {isDesktop ? (
<div className="relative"> <div className="relative">
<Search className="text-muted-foreground absolute top-[0.8rem] left-2 size-4" /> <Search className="text-muted-foreground absolute top-[0.8rem] left-2 size-4" />
<label htmlFor="search" className="sr-only"> <label htmlFor="search" className="sr-only">
Search Chats Search Chats
</label> </label>
<Input placeholder="Search" className="m-0 pl-8" id="search" /> <Input placeholder="Search" className="m-0 pl-8" id="search" />
</div> </div>
) : ( ) : (
<Button <Button
aria-label="Open Search Dialog" aria-label="Open Search Dialog"
onClick={() => setSearchDialogOpen(true)} onClick={() => setSearchDialogOpen(true)}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-8" className="size-8"
> >
<Search size={20} /> <Search size={20} />
</Button> </Button>
)} )}
<SearchUserComponent <SearchUserComponent
activeChats={activeChats} activeChats={activeChats}
isOpen={searchDialogOpen} isOpen={searchDialogOpen}
OpenChange={setSearchDialogOpen} OpenChange={setSearchDialogOpen}
handleChatSelection={handleChatSelection} handleChatSelection={handleChatSelection}
/> />
<Button <Button
aria-label="Open Add User Dialog" aria-label="Open Add User Dialog"
onClick={() => setAddDialogOpen(true)} onClick={() => setAddDialogOpen(true)}
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-8" className="size-8"
> >
<SquarePen size={20} /> <SquarePen size={20} />
</Button> </Button>
<NewUserComponent <NewUserComponent
inactiveUsers={inactiveUsers} inactiveUsers={inactiveUsers}
isOpen={addDialogOpen} isOpen={addDialogOpen}
OpenChange={setAddDialogOpen} OpenChange={setAddDialogOpen}
/> />
</div> </div>
<nav className="grid gap-1 rounded-lg group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2"> <nav className="grid gap-1 rounded-lg group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{activeChats.map((chat) => ( {activeChats.map((chat) => (
<ContextMenu key={chat.userinfo.username}> <ContextMenu key={chat.userinfo.username}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<Button <Button
aria-label="Select Chat" aria-label="Select Chat"
onClick={() => handleChatSelection(chat.id)} onClick={() => handleChatSelection(chat.id)}
className={cn( className={cn(
buttonVariants({ buttonVariants({
variant: getVariant(chat.lastmessage), variant: getVariant(chat.lastmessage),
size: !isDesktop ? "icon" : "xl", size: !isDesktop ? "icon" : "xl",
}), }),
!isDesktop ? "p-1" : "justify-start gap-4", !isDesktop ? "p-1" : "justify-start gap-4",
"h-fit w-full", "h-fit w-full",
)} )}
> >
<UserAvatar <UserAvatar
username={chat.userinfo.username} username={chat.userinfo.username}
userId={chat.userinfo.id} userId={chat.userinfo.id}
className="size-10" className="size-10"
/> />
{isDesktop && ( {isDesktop && (
<div className="flex h-full max-w-28 flex-col text-left align-top"> <div className="flex h-full max-w-28 flex-col text-left align-top">
<span>{chat.userinfo.username}</span> <span>{chat.userinfo.username}</span>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{chat.etichette.map((etichetta) => ( {chat.etichette.map((etichetta) => (
<Etichetta <Etichetta
key={etichetta.id_etichetta} key={etichetta.id_etichetta}
title={etichetta.title} title={etichetta.title}
color_hex={etichetta.color_hex} color_hex={etichetta.color_hex}
className="text-xs" className="text-xs"
/> />
))} ))}
</div> </div>
<span className="text-muted-foreground truncate text-xs"> <span className="text-muted-foreground truncate text-xs">
{getLastMessage(chat.lastmessage)} {getLastMessage(chat.lastmessage)}
</span> </span>
<span className="text-muted-foreground truncate text-xs"> <span className="text-muted-foreground truncate text-xs">
{chat.lastmessage && {chat.lastmessage &&
new Date(chat.lastmessage.time).toLocaleString("it", { new Date(chat.lastmessage.time).toLocaleString("it", {
day: "2-digit", day: "2-digit",
month: "2-digit", month: "2-digit",
year: "numeric", year: "numeric",
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
})} })}
</span> </span>
</div> </div>
)} )}
</Button> </Button>
</ContextMenuTrigger> </ContextMenuTrigger>
<ChatSidebarContextMenu <ChatSidebarContextMenu
chatid={chat.id} chatid={chat.id}
setSelectedChat={setSelectedChat} setSelectedChat={setSelectedChat}
setDeleteModalOpen={setDeleteModalOpen} setDeleteModalOpen={setDeleteModalOpen}
setEditEtichettaModalOpen={setEditEtichettaModalOpen} setEditEtichettaModalOpen={setEditEtichettaModalOpen}
/> />
</ContextMenu> </ContextMenu>
))} ))}
{selectedChat && ( {selectedChat && (
<> <>
<AlertDialog <AlertDialog
open={deleteModalOpen} open={deleteModalOpen}
onOpenChange={setDeleteModalOpen} onOpenChange={setDeleteModalOpen}
> >
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle> <AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Questa azione non può essere annullata. Questo eliminerà Questa azione non può essere annullata. Questo eliminerà
definitivamente la chat e rimuoverà i dati dai nostri definitivamente la chat e rimuoverà i dati dai nostri
server. server.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel> <AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
aria-label="Delete Chat" aria-label="Delete Chat"
className={cn(buttonVariants({ variant: "destructive" }))} className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => { onClick={() => {
if (selectedChat) { if (selectedChat) {
deletechat({ deletechat({
chatId: selectedChat, chatId: selectedChat,
}); });
} }
}} }}
> >
Continua Continua
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
<EtichetteModal <EtichetteModal
open={editEtichettaModalOpen} open={editEtichettaModalOpen}
onOpenChange={setEditEtichettaModalOpen} onOpenChange={setEditEtichettaModalOpen}
chatId={selectedChat} chatId={selectedChat}
/> />
</> </>
)} )}
</nav> </nav>
</div> </div>
); );
}; };
const SearchUserComponent = ({ const SearchUserComponent = ({
isOpen, isOpen,
OpenChange, OpenChange,
activeChats, activeChats,
handleChatSelection, handleChatSelection,
}: { }: {
isOpen: boolean; isOpen: boolean;
OpenChange: (status: boolean) => void; OpenChange: (status: boolean) => void;
activeChats: ActiveChatsType[]; activeChats: ActiveChatsType[];
handleChatSelection: (chatId: ChatsChatid) => void; handleChatSelection: (chatId: ChatsChatid) => void;
}) => { }) => {
return ( return (
<CommandDialog open={isOpen} onOpenChange={OpenChange}> <CommandDialog open={isOpen} onOpenChange={OpenChange}>
<CommandInput <CommandInput
placeholder="Digita il nome utente..." placeholder="Digita il nome utente..."
className="border-none ring-0 focus:ring-0 focus-visible:ring-0" className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/> />
<CommandList> <CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty> <CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup <CommandGroup
className="pb-2" className="pb-2"
heading={activeChats.length > 0 ? "Suggerimenti" : undefined} heading={activeChats.length > 0 ? "Suggerimenti" : undefined}
> >
{activeChats?.map((chat) => ( {activeChats?.map((chat) => (
<CommandItem <CommandItem
className="rounded-md" className="rounded-md"
key={chat.id} key={chat.id}
onSelect={() => { onSelect={() => {
handleChatSelection(chat.id); handleChatSelection(chat.id);
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<UserAvatar <UserAvatar
username={chat.userinfo.username} username={chat.userinfo.username}
userId={chat.userinfo.id} userId={chat.userinfo.id}
className="size-8" className="size-8"
/> />
<span> <span>
<span className="block font-medium"> <span className="block font-medium">
{chat.userinfo.username} {chat.userinfo.username}
</span> </span>
</span> </span>
</span> </span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
); );
}; };
const NewUserComponent = ({ const NewUserComponent = ({
isOpen, isOpen,
OpenChange, OpenChange,
inactiveUsers, inactiveUsers,
}: { }: {
isOpen: boolean; isOpen: boolean;
OpenChange: (status: boolean) => void; OpenChange: (status: boolean) => void;
inactiveUsers: NonNullableUser[]; inactiveUsers: NonNullableUser[];
}) => { }) => {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: createNewChat } = api.chatSSE.create.useMutation({ const { mutate: createNewChat } = api.chatSSE.create.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.chat.getActiveChats.invalidate(); await utils.chat.getActiveChats.invalidate();
OpenChange(false); OpenChange(false);
}, },
}); });
return ( return (
<CommandDialog open={isOpen} onOpenChange={OpenChange}> <CommandDialog open={isOpen} onOpenChange={OpenChange}>
<CommandInput <CommandInput
placeholder="Digita il nome utente..." placeholder="Digita il nome utente..."
className="border-none ring-0 focus:ring-0 focus-visible:ring-0" className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/> />
<CommandList> <CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty> <CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup <CommandGroup
className="pb-2" className="pb-2"
heading={inactiveUsers.length > 0 ? "Suggerimenti" : undefined} heading={inactiveUsers.length > 0 ? "Suggerimenti" : undefined}
> >
{inactiveUsers?.map((user) => ( {inactiveUsers?.map((user) => (
<CommandItem <CommandItem
className="rounded-md" className="rounded-md"
key={user.id} key={user.id}
onSelect={() => { onSelect={() => {
createNewChat({ createNewChat({
userId: user.id, userId: user.id,
}); });
}} }}
> >
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<UserAvatar <UserAvatar
username={user.username} username={user.username}
userId={user.id} userId={user.id}
className="size-8" className="size-8"
/> />
<span> <span>
<span className="block font-medium">{user.username}</span> <span className="block font-medium">{user.username}</span>
</span> </span>
</span> </span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</CommandDialog> </CommandDialog>
); );
}; };
const ChatSidebarContextMenu = ({ const ChatSidebarContextMenu = ({
chatid, chatid,
setSelectedChat, setSelectedChat,
setDeleteModalOpen, setDeleteModalOpen,
setEditEtichettaModalOpen, setEditEtichettaModalOpen,
}: { }: {
chatid: ChatsChatid; chatid: ChatsChatid;
setSelectedChat: (chat: ChatsChatid | null) => void; setSelectedChat: (chat: ChatsChatid | null) => void;
setDeleteModalOpen: (status: boolean) => void; setDeleteModalOpen: (status: boolean) => void;
setEditEtichettaModalOpen: (status: boolean) => void; setEditEtichettaModalOpen: (status: boolean) => void;
}) => { }) => {
const { data: chatData } = api.chat.getChatInfobyId.useQuery({ const { data: chatData } = api.chat.getChatInfobyId.useQuery({
chatId: chatid, chatId: chatid,
}); });
if (!chatData) return null; if (!chatData) return null;
return ( return (
<> <>
<ContextMenuContent> <ContextMenuContent>
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger>Etichette</ContextMenuSubTrigger> <ContextMenuSubTrigger>Etichette</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48"> <ContextMenuSubContent className="w-48">
<ContextMenuItem asChild> <ContextMenuItem asChild>
<button <button
aria-label="Edit Chat Labels" type="button"
onClick={() => { aria-label="Edit Chat Labels"
setSelectedChat(chatid); onClick={() => {
setEditEtichettaModalOpen(true); setSelectedChat(chatid);
}} setEditEtichettaModalOpen(true);
className="w-full cursor-pointer" }}
> className="w-full cursor-pointer"
Modifica >
</button> Modifica
</ContextMenuItem> </button>
</ContextMenuSubContent> </ContextMenuItem>
</ContextMenuSub> </ContextMenuSubContent>
<ContextMenuSeparator /> </ContextMenuSub>
<ContextMenuItem> <ContextMenuSeparator />
<Link <ContextMenuItem>
aria-label="View User Profile" <Link
target="_blank" aria-label="View User Profile"
href={`/area-riservata/admin/user-view/edit-user/${chatData.user_ref}`} target="_blank"
> href={`/area-riservata/admin/user-view/edit-user/${chatData.user_ref}`}
Profilo >
</Link> Profilo
</ContextMenuItem> </Link>
</ContextMenuItem>
<ContextMenuItem> <ContextMenuItem>
<Link <Link
aria-label="View User Orders" aria-label="View User Orders"
target="_blank" target="_blank"
href={`/area-riservata/admin/user-view/ordini/${chatData.user_ref}`} href={`/area-riservata/admin/user-view/ordini/${chatData.user_ref}`}
> >
Ordini Ordini
</Link> </Link>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuItem> <ContextMenuItem>
<Link <Link
aria-label="View User Attachments" aria-label="View User Attachments"
target="_blank" target="_blank"
href={`/area-riservata/admin/user-view/allegati/${chatData.user_ref}`} href={`/area-riservata/admin/user-view/allegati/${chatData.user_ref}`}
> >
Allegati Allegati
</Link> </Link>
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSub> <ContextMenuSub>
<ContextMenuSubTrigger>Altro</ContextMenuSubTrigger> <ContextMenuSubTrigger>Altro</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48"> <ContextMenuSubContent className="w-48">
<ContextMenuItem <ContextMenuItem
aria-label="Edit Chat" aria-label="Edit Chat"
onClick={() => { onClick={() => {
setSelectedChat(chatid); setSelectedChat(chatid);
setDeleteModalOpen(true); setDeleteModalOpen(true);
}} }}
className="cursor-pointer rounded-md bg-red-500 text-white hover:bg-red-500/90 focus:bg-red-500/90 focus:text-white" className="cursor-pointer rounded-md bg-red-500 text-white hover:bg-red-500/90 focus:bg-red-500/90 focus:text-white"
> >
Elimina Chat Elimina Chat
</ContextMenuItem> </ContextMenuItem>
</ContextMenuSubContent> </ContextMenuSubContent>
</ContextMenuSub> </ContextMenuSub>
</ContextMenuContent> </ContextMenuContent>
</> </>
); );
}; };

View file

@ -1,269 +1,269 @@
"use client"; "use client";
import { import {
Info, Info,
Paperclip, Paperclip,
ShieldUser, ShieldUser,
ShoppingBag, ShoppingBag,
User, User,
UserCog, UserCog,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
import { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button";
import Image from "next/image"; import Image from "next/image";
import { import Link from "next/link";
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
AlertDialog,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
AlertDialogCancel,
AlertDialogAction,
} from "~/components/ui/alert-dialog";
import { api } from "~/utils/api";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useCallback } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Separator } from "~/components/ui/separator";
import { EtichetteDisplayer } from "~/components/etichette"; import { EtichetteDisplayer } from "~/components/etichette";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "~/components/ui/alert-dialog";
import { Button, buttonVariants } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Separator } from "~/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { Session } from "~/server/api/trpc"; import type { Session } from "~/server/api/trpc";
import type { ChatUserInfo } from "~/server/services/chat.service"; import type { ChatUserInfo } from "~/server/services/chat.service";
import type { ChatsChatid } from "~/schemas/public/Chats";
import type { OnlineStatus } from "~/server/sse"; import type { OnlineStatus } from "~/server/sse";
import { import { api } from "~/utils/api";
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import { useCallback } from "react";
import { UserAvatar } from "~/components/user_avatar";
type ChatTopbarProps = { type ChatTopbarProps = {
chatId: ChatsChatid; chatId: ChatsChatid;
userData: Session; userData: Session;
chatUserData: ChatUserInfo; chatUserData: ChatUserInfo;
onlineStatus: OnlineStatus[]; onlineStatus: OnlineStatus[];
}; };
export default function ChatTopbar({ export default function ChatTopbar({
chatId, chatId,
userData, userData,
chatUserData, chatUserData,
onlineStatus, onlineStatus,
}: ChatTopbarProps) { }: ChatTopbarProps) {
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: deletechat } = api.chat.deleteChat.useMutation({ const { mutate: deletechat } = api.chat.deleteChat.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.chat.getActiveChats.invalidate(); await utils.chat.getActiveChats.invalidate();
await utils.users.getActiveUsersWithoutChat.invalidate(); await utils.users.getActiveUsersWithoutChat.invalidate();
await router.push("/area-riservata/admin/chats/"); await router.push("/area-riservata/admin/chats/");
}, },
}); });
const router = useRouter(); const router = useRouter();
const { mutate: swap } = api.auth.swapUser.useMutation({ const { mutate: swap } = api.auth.swapUser.useMutation({
onSuccess: async () => { onSuccess: async () => {
toast.success("Utente cambiato con successo"); toast.success("Utente cambiato con successo");
await router.push("/area-riservata/dashboard"); await router.push("/area-riservata/dashboard");
router.reload(); router.reload();
}, },
}); });
const handleDeleteChat = useCallback(() => { const handleDeleteChat = useCallback(() => {
deletechat({ chatId }); deletechat({ chatId });
}, [chatId, deletechat]); }, [chatId, deletechat]);
const handleSwapUser = useCallback(() => { const handleSwapUser = useCallback(() => {
swap({ id: chatUserData.id }); swap({ id: chatUserData.id });
}, [chatUserData, swap]); }, [chatUserData, swap]);
return ( return (
<div className="flex h-18 w-full items-center justify-between border-b px-4 py-2"> <div className="flex h-18 w-full items-center justify-between border-b px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!userData.isAdmin ? ( {!userData.isAdmin ? (
<Image <Image
src={"/favicon/favicon.png"} src={"/favicon/favicon.png"}
alt="Infoalloggi logo" alt="Infoalloggi logo"
width={40} width={40}
height={40} height={40}
className="size-10 rounded-full" className="size-10 rounded-full"
/> />
) : ( ) : (
<UserAvatar <UserAvatar
className="size-10" className="size-10"
username={chatUserData.username} username={chatUserData.username}
userId={chatUserData.id} userId={chatUserData.id}
/> />
)} )}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<div className="flex gap-4"> <div className="flex gap-4">
<span className="font-medium"> <span className="font-medium">
{!userData.isAdmin ? "Chat Infoalloggi" : chatUserData.username} {!userData.isAdmin ? "Chat Infoalloggi" : chatUserData.username}
</span> </span>
{userData.isAdmin && <EtichetteDisplayer chatId={chatId} />} {userData.isAdmin && <EtichetteDisplayer chatId={chatId} />}
</div> </div>
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
<UserStatus onlineStatus={onlineStatus} userData={userData} /> <UserStatus onlineStatus={onlineStatus} userData={userData} />
</div> </div>
</div> </div>
</div> </div>
<div> <div>
{userData.isAdmin && ( {userData.isAdmin && (
<AdminPopover <AdminPopover
chatUserData={chatUserData} chatUserData={chatUserData}
handleSwapUser={handleSwapUser} handleSwapUser={handleSwapUser}
handleDeleteChat={handleDeleteChat} handleDeleteChat={handleDeleteChat}
/> />
)} )}
</div> </div>
</div> </div>
); );
} }
const UserStatus = ({ const UserStatus = ({
onlineStatus, onlineStatus,
userData, userData,
}: { }: {
onlineStatus: OnlineStatus[]; onlineStatus: OnlineStatus[];
userData: Session; userData: Session;
}) => ( }) => (
<div className="flex flex-row items-center gap-2"> <div className="flex flex-row items-center gap-2">
{onlineStatus.map((user, idx) => {onlineStatus.map((user, idx) =>
user.isAdmin ? ( user.isAdmin ? (
<TooltipProvider key={idx} delayDuration={0}> <TooltipProvider key={idx} delayDuration={0}>
<Tooltip> <Tooltip>
<TooltipTrigger> <TooltipTrigger>
<span <span
className={cn( className={cn(
"flex flex-row items-center text-xs font-bold text-green-500", "flex flex-row items-center text-xs font-bold text-green-500",
)} )}
> >
<ShieldUser size={15} className="mr-[0.2rem]" /> <ShieldUser size={15} className="mr-[0.2rem]" />
<span> <span>
{user.nome} {user.nome}
{idx < onlineStatus.length - 1 && ","} {idx < onlineStatus.length - 1 && ","}
</span> </span>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<p>Admin Infoalloggi</p> <p>Admin Infoalloggi</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) : ( ) : (
<span <span
key={idx} key={idx}
className={cn("flex flex-row text-xs font-bold text-green-500")} className={cn("flex flex-row text-xs font-bold text-green-500")}
> >
<span> <span>
{user.nome} {user.nome}
{!userData.isAdmin && " (Tu)"} {!userData.isAdmin && " (Tu)"}
{idx < onlineStatus.length - 1 && ", "} {idx < onlineStatus.length - 1 && ", "}
</span> </span>
</span> </span>
), ),
)} )}
</div> </div>
); );
const AdminPopover = ({ const AdminPopover = ({
chatUserData, chatUserData,
handleSwapUser, handleSwapUser,
handleDeleteChat, handleDeleteChat,
}: { }: {
chatUserData: ChatUserInfo; chatUserData: ChatUserInfo;
handleSwapUser: () => void; handleSwapUser: () => void;
handleDeleteChat: () => void; handleDeleteChat: () => void;
}) => ( }) => (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
className={cn( className={cn(
buttonVariants({ variant: "outline", size: "icon" }), buttonVariants({ variant: "outline", size: "icon" }),
"size-10", "size-10",
"dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white", "dark:bg-muted dark:text-muted-foreground dark:hover:bg-muted dark:hover:text-white",
)} )}
> >
<Info size={24} className="text-muted-foreground" /> <Info size={24} className="text-muted-foreground" />
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="mr-4 w-fit px-4"> <PopoverContent className="mr-4 w-fit px-4">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Link <Link
aria-label="Visualizza Profilo Utente" aria-label="Visualizza Profilo Utente"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"justify-start gap-2", "justify-start gap-2",
)} )}
href={`/area-riservata/admin/user-view/edit-user/${chatUserData.id}`} href={`/area-riservata/admin/user-view/edit-user/${chatUserData.id}`}
> >
<UserCog /> Profilo <UserCog /> Profilo
</Link> </Link>
<Link <Link
aria-label="Visualizza Ordini Utente" aria-label="Visualizza Ordini Utente"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"justify-start gap-2", "justify-start gap-2",
)} )}
href={`/area-riservata/admin/user-view/ordini/${chatUserData.id}`} href={`/area-riservata/admin/user-view/ordini/${chatUserData.id}`}
> >
<ShoppingBag /> Ordini <ShoppingBag /> Ordini
</Link> </Link>
<Link <Link
aria-label="Visualizza Allegati Utente" aria-label="Visualizza Allegati Utente"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"justify-start gap-2", "justify-start gap-2",
)} )}
href={`/area-riservata/admin/user-view/allegati/${chatUserData.id}`} href={`/area-riservata/admin/user-view/allegati/${chatUserData.id}`}
> >
<Paperclip /> Allegati <Paperclip /> Allegati
</Link> </Link>
<button <button
aria-label="Cambia Utente" aria-label="Cambia Utente"
className={cn( className={cn(
buttonVariants({ variant: "outline" }), buttonVariants({ variant: "outline" }),
"justify-start gap-2", "justify-start gap-2",
)} )}
type="button" type="button"
onClick={handleSwapUser} onClick={handleSwapUser}
> >
<User /> Login <User /> Login
</button> </button>
<Separator /> <Separator />
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button size="sm" variant="destructive"> <Button size="sm" variant="destructive">
Cancella Chat Cancella Chat
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle> <AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
Questa azione non può essere annullata. Questo eliminerà Questa azione non può essere annullata. Questo eliminerà
definitivamente la chat e rimuoverà i dati dai nostri server. definitivamente la chat e rimuoverà i dati dai nostri server.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel> <AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
aria-label="Continua Eliminazione Chat" aria-label="Continua Eliminazione Chat"
className={cn(buttonVariants({ variant: "destructive" }))} className={cn(buttonVariants({ variant: "destructive" }))}
onClick={handleDeleteChat} onClick={handleDeleteChat}
> >
Continua Continua
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );

View file

@ -1,52 +1,52 @@
"use client"; "use client";
import type { ChatsChatid } from "~/schemas/public/Chats"; import { memo } from "react";
import ChatBottombar from "~/components/chat/chat-bottombar"; import ChatBottombar from "~/components/chat/chat-bottombar";
import { ChatList } from "~/components/chat/chat-list"; import { ChatList } from "~/components/chat/chat-list";
import ChatTopbar from "~/components/chat/chat-topbar"; import ChatTopbar from "~/components/chat/chat-topbar";
import { useLiveChat } from "~/hooks/chatHooks"; import { useLiveChat } from "~/hooks/chatHooks";
import { api } from "~/utils/api"; import type { ChatsChatid } from "~/schemas/public/Chats";
import type { Session } from "~/server/api/trpc"; import type { Session } from "~/server/api/trpc";
import { memo } from "react"; import { api } from "~/utils/api";
const Chat = memo( const Chat = memo(
({ chatId, userData }: { chatId: ChatsChatid; userData: Session }) => { ({ chatId, userData }: { chatId: ChatsChatid; userData: Session }) => {
const liveChat = useLiveChat(chatId, userData); const liveChat = useLiveChat(chatId, userData);
const [userinfo] = api.chat.getUserInfosByChat.useSuspenseQuery({ const [userinfo] = api.chat.getUserInfosByChat.useSuspenseQuery({
chatId, chatId,
}); });
return ( return (
<div className="flex h-full w-full flex-col justify-between"> <div className="flex h-full w-full flex-col justify-between">
<div className="shrink-0"> <div className="shrink-0">
<ChatTopbar <ChatTopbar
chatId={chatId} chatId={chatId}
userData={userData} userData={userData}
chatUserData={userinfo} chatUserData={userinfo}
onlineStatus={liveChat.onlineUsers} onlineStatus={liveChat.onlineUsers}
/> />
</div> </div>
<ChatList <ChatList
chatId={chatId} chatId={chatId}
userData={userData} userData={userData}
messages={liveChat.messages} messages={liveChat.messages}
query={liveChat.query} query={liveChat.query}
typingStatus={liveChat.typingStatus} typingStatus={liveChat.typingStatus}
/> />
<div className="shrink-0 border-t"> <div className="shrink-0 border-t">
<ChatBottombar <ChatBottombar
chatId={chatId} chatId={chatId}
userData={userData} userData={userData}
chatUserData={userinfo} chatUserData={userinfo}
/> />
</div> </div>
</div> </div>
); );
}, },
(prevProps, nextProps) => (prevProps, nextProps) =>
prevProps.chatId === nextProps.chatId && prevProps.chatId === nextProps.chatId &&
prevProps.userData.id === nextProps.userData.id, prevProps.userData.id === nextProps.userData.id,
); );
Chat.displayName = "Chat"; Chat.displayName = "Chat";

View file

@ -1,45 +1,46 @@
// @hidden // @hidden
export default function MessageLoading() { export default function MessageLoading() {
return ( return (
<svg <svg
width="24" width="24"
height="24" height="24"
viewBox="0 0 24 24" viewBox="0 0 24 24"
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
className="text-foreground" className="text-foreground"
> >
<circle cx="4" cy="12" r="2" fill="currentColor"> <title>Loading</title>
<animate <circle cx="4" cy="12" r="2" fill="currentColor">
id="spinner_qFRN" <animate
begin="0;spinner_OcgL.end+0.25s" id="spinner_qFRN"
attributeName="cy" begin="0;spinner_OcgL.end+0.25s"
calcMode="spline" attributeName="cy"
dur="0.6s" calcMode="spline"
values="12;6;12" dur="0.6s"
keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"
/> keySplines=".33,.66,.66,1;.33,0,.66,.33"
</circle> />
<circle cx="12" cy="12" r="2" fill="currentColor"> </circle>
<animate <circle cx="12" cy="12" r="2" fill="currentColor">
begin="spinner_qFRN.begin+0.1s" <animate
attributeName="cy" begin="spinner_qFRN.begin+0.1s"
calcMode="spline" attributeName="cy"
dur="0.6s" calcMode="spline"
values="12;6;12" dur="0.6s"
keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"
/> keySplines=".33,.66,.66,1;.33,0,.66,.33"
</circle> />
<circle cx="20" cy="12" r="2" fill="currentColor"> </circle>
<animate <circle cx="20" cy="12" r="2" fill="currentColor">
id="spinner_OcgL" <animate
begin="spinner_qFRN.begin+0.2s" id="spinner_OcgL"
attributeName="cy" begin="spinner_qFRN.begin+0.2s"
calcMode="spline" attributeName="cy"
dur="0.6s" calcMode="spline"
values="12;6;12" dur="0.6s"
keySplines=".33,.66,.66,1;.33,0,.66,.33" values="12;6;12"
/> keySplines=".33,.66,.66,1;.33,0,.66,.33"
</circle> />
</svg> </circle>
); </svg>
);
} }

View file

@ -1,184 +1,187 @@
"use client"; "use client";
import { useRouter } from "next/router"; import { CircleOff } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { api } from "~/utils/api"; import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import Input from "~/components/custom_ui/input"; import Input from "~/components/custom_ui/input";
import { cn } from "~/lib/utils";
import { StaggeredFade } from "~/components/custom_ui/staggered-fade"; import { StaggeredFade } from "~/components/custom_ui/staggered-fade";
import { useTranslation } from "~/providers/I18nProvider";
import { useClickOutside } from "~/hooks/useClickOutside"; import { useClickOutside } from "~/hooks/useClickOutside";
import { CircleOff } from "lucide-react"; import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { api } from "~/utils/api";
export const CodiceBox = ({ export const CodiceBox = ({
className, className,
optionBoxClassName, optionBoxClassName,
optionsClassName, optionsClassName,
}: { }: {
className?: string; className?: string;
optionBoxClassName?: string; optionBoxClassName?: string;
optionsClassName?: string; optionsClassName?: string;
}) => { }) => {
const { data: codici, isLoading: loading } = api.annunci.getCodici.useQuery(); const { data: codici, isLoading: loading } = api.annunci.getCodici.useQuery();
const { locale } = useTranslation(); const { locale } = useTranslation();
return ( return (
<AutocompleteSearchBox <AutocompleteSearchBox
key={locale} key={locale}
options={codici || []} options={codici || []}
loading={loading} loading={loading}
className={className} className={className}
optionBoxClassName={optionBoxClassName} optionBoxClassName={optionBoxClassName}
optionsClassName={optionsClassName} optionsClassName={optionsClassName}
/> />
); );
}; };
type SearchBoxProps = { type SearchBoxProps = {
options: string[]; options: string[];
loading: boolean; loading: boolean;
className?: string; className?: string;
optionBoxClassName?: string; optionBoxClassName?: string;
optionsClassName?: string; optionsClassName?: string;
}; };
const AutocompleteSearchBox = ({ const AutocompleteSearchBox = ({
options, options,
loading, loading,
className, className,
optionBoxClassName, optionBoxClassName,
optionsClassName, optionsClassName,
}: SearchBoxProps) => { }: SearchBoxProps) => {
const router = useRouter(); const router = useRouter();
const [open, setOpen] = useState<boolean>(false); const [open, setOpen] = useState<boolean>(false);
const [results, setResults] = useState<string[]>(options); const [results, setResults] = useState<string[]>(options);
const [isFocused, setIsFocused] = useState<boolean>(false); const [isFocused, setIsFocused] = useState<boolean>(false);
const [searchValue, setSearchValue] = useState<string>(""); const [searchValue, setSearchValue] = useState<string>("");
const [navigationEnter, setNavigationEnter] = useState<boolean>(false); const [navigationEnter, setNavigationEnter] = useState<boolean>(false);
const wrapperRef = useRef<HTMLDivElement | null>(null); const wrapperRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<HTMLDivElement | null>(null); const listRef = useRef<HTMLDivElement | null>(null);
useClickOutside([wrapperRef, listRef], () => setOpen(false)); useClickOutside([wrapperRef, listRef], () => setOpen(false));
useEffect(() => { useEffect(() => {
if (searchValue.trim() == "" || searchValue.length == 0) { if (searchValue.trim() === "" || searchValue.length === 0) {
setResults(options); setResults(options);
} else { } else {
setResults( setResults(
options.filter((option) => option.includes(searchValue.trim())), options.filter((option) => option.includes(searchValue.trim())),
); );
} }
}, [searchValue, options]); }, [searchValue, options]);
return ( return (
<div ref={wrapperRef} className="z-[5000] w-full"> <div ref={wrapperRef} className="z-[5000] w-full">
<div className="relative flex w-full items-center justify-center rounded-md bg-white"> <div className="relative flex w-full items-center justify-center rounded-md bg-white">
<label htmlFor="search" className="sr-only"> <label htmlFor="search" className="sr-only">
Search Box Search Box
</label> </label>
<Input <Input
id="search" id="search"
type="search" type="search"
value={searchValue} value={searchValue}
onChange={(e) => setSearchValue(e.target.value)} onChange={(e) => setSearchValue(e.target.value)}
onFocus={() => { onFocus={() => {
setOpen(true); setOpen(true);
setIsFocused(true); setIsFocused(true);
}} }}
onBlur={() => { onBlur={() => {
setIsFocused(false); setIsFocused(false);
}} }}
onKeyDown={async (e) => { onKeyDown={async (e) => {
if (e.key === "Enter" && results[0] && results.length > 0) { if (e.key === "Enter" && results[0] && results.length > 0) {
setNavigationEnter(true); setNavigationEnter(true);
await router.push(`/annuncio/${results[0].trim()}`); await router.push(`/annuncio/${results[0].trim()}`);
} }
}} }}
className={cn( className={cn(
"placeholder:text-primary search-cancel:brightness-75 search-cancel:grayscale z-10 cursor-pointer rounded-md bg-transparent text-xl placeholder:font-bold", "placeholder:text-primary search-cancel:brightness-75 search-cancel:grayscale z-10 cursor-pointer rounded-md bg-transparent text-xl placeholder:font-bold",
className, className,
)} )}
/> />
<MovingText hidden={isFocused || searchValue != ""} /> <MovingText hidden={isFocused || searchValue !== ""} />
</div> </div>
{open && ( {open && (
<div <div
ref={listRef} ref={listRef}
className={cn( className={cn(
"border-foreground ring-foreground absolute z-50 mt-1 max-h-64 w-full overflow-hidden rounded-md border bg-white shadow-xs ring-1", "border-foreground ring-foreground absolute z-50 mt-1 max-h-64 w-full overflow-hidden rounded-md border bg-white shadow-xs ring-1",
optionBoxClassName, optionBoxClassName,
)} )}
> >
<ul <ul
className={cn( className={cn(
"max-h-64 w-full overflow-y-auto", "max-h-64 w-full overflow-y-auto",
navigationEnter && navigationEnter &&
"[&>li:first-child>a]:bg-neutral-100 [&>li:first-child>a]:underline [&>li:first-child>a]:underline-offset-2", "[&>li:first-child>a]:bg-neutral-100 [&>li:first-child>a]:underline [&>li:first-child>a]:underline-offset-2",
)} )}
role="listbox" // biome-ignore lint/a11y/noNoninteractiveElementToInteractiveRole: <need list>
> role="listbox"
{loading ? ( >
<li className={"px-3 py-2 text-center text-xl text-neutral-600"}> {loading ? (
<span>Caricamento...</span> <li className={"px-3 py-2 text-center text-xl text-neutral-600"}>
</li> <span>Caricamento...</span>
) : ( </li>
<> ) : (
{results.length === 0 && ( <>
<li {results.length === 0 && (
className={"px-3 py-2 text-center text-xl text-neutral-600"} <li
> className={"px-3 py-2 text-center text-xl text-neutral-600"}
<span> >
<CircleOff className="inline size-6" /> <span>
</span> <CircleOff className="inline size-6" />
</li> </span>
)} </li>
)}
{results.map((item) => ( {results.map((item) => (
<li key={item}> <li key={item}>
<Link <Link
href={`/annuncio/${item}`} href={`/annuncio/${item}`}
className={cn( className={cn(
"flex w-full cursor-pointer items-center justify-between border-b border-neutral-200 bg-white px-4 py-2 text-xl text-neutral-500 duration-150 hover:bg-neutral-100 hover:text-neutral-600 hover:underline hover:underline-offset-2", "flex w-full cursor-pointer items-center justify-between border-b border-neutral-200 bg-white px-4 py-2 text-xl text-neutral-500 duration-150 hover:bg-neutral-100 hover:text-neutral-600 hover:underline hover:underline-offset-2",
optionsClassName, optionsClassName,
)} )}
> >
{item} {item}
</Link> </Link>
</li> </li>
))} ))}
</> </>
)} )}
</ul> </ul>
</div> </div>
)} )}
</div> </div>
); );
}; };
const MovingText = ({ hidden }: { hidden: boolean }) => { const MovingText = ({ hidden }: { hidden: boolean }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [randomFrase, setRandomFrase] = useState<string>( const [randomFrase, setRandomFrase] = useState<string>(
t.code_search_frases[0]!, // biome-ignore lint/style/noNonNullAssertion: <exists>
); t.code_search_frases[0]!,
useEffect(() => { );
const interval = setInterval(() => { useEffect(() => {
const randomIndex = Math.floor( const interval = setInterval(() => {
Math.random() * t.code_search_frases.length, const randomIndex = Math.floor(
); Math.random() * t.code_search_frases.length,
setRandomFrase(t.code_search_frases[randomIndex]!); );
}, 5000); // biome-ignore lint/style/noNonNullAssertion: <exists>
setRandomFrase(t.code_search_frases[randomIndex]!);
}, 5000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [t]); }, [t]);
return ( return (
<StaggeredFade <StaggeredFade
key={randomFrase} key={randomFrase}
text={randomFrase} text={randomFrase}
className="absolute text-2xl font-bold dark:text-neutral-800" className="absolute text-2xl font-bold dark:text-neutral-800"
hide={hidden} hide={hidden}
/> />
); );
}; };

View file

@ -1,70 +1,70 @@
import { forwardRef, useMemo, useState } from "react"; import { forwardRef, useMemo, useState } from "react";
import { HexColorPicker } from "react-colorful"; import { HexColorPicker } from "react-colorful";
import { cn } from "~/lib/utils";
import { useForwardedRef } from "~/lib/use_fowarded_ref";
import type { ButtonProps } from "~/components/ui/button"; import type { ButtonProps } from "~/components/ui/button";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { useForwardedRef } from "~/lib/use_fowarded_ref";
import { cn } from "~/lib/utils";
interface ColorPickerProps { interface ColorPickerProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
onBlur?: () => void; onBlur?: () => void;
} }
const ColorPicker = forwardRef< const ColorPicker = forwardRef<
HTMLInputElement, HTMLInputElement,
Omit<ButtonProps, "value" | "onChange" | "onBlur"> & ColorPickerProps Omit<ButtonProps, "value" | "onChange" | "onBlur"> & ColorPickerProps
>( >(
( (
{ disabled, value, onChange, onBlur, name, className, ...props }, { disabled, value, onChange, onBlur, name, className, ...props },
forwardedRef, forwardedRef,
) => { ) => {
const ref = useForwardedRef(forwardedRef); const ref = useForwardedRef(forwardedRef);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const parsedValue = useMemo(() => { const parsedValue = useMemo(() => {
return value || "#FFFFFF"; return value || "#FFFFFF";
}, [value]); }, [value]);
return ( return (
<Popover onOpenChange={setOpen} open={open}> <Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild disabled={disabled} onBlur={onBlur}> <PopoverTrigger asChild disabled={disabled} onBlur={onBlur}>
<Button <Button
{...props} {...props}
className={cn("block", className)} className={cn("block", className)}
name={name} name={name}
onClick={() => { onClick={() => {
setOpen(true); setOpen(true);
}} }}
size="icon" size="icon"
style={{ style={{
backgroundColor: parsedValue, backgroundColor: parsedValue,
}} }}
variant="outline" variant="outline"
> >
<div /> <div />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-full"> <PopoverContent className="w-full">
<HexColorPicker color={parsedValue} onChange={onChange} /> <HexColorPicker color={parsedValue} onChange={onChange} />
<Input <Input
maxLength={7} maxLength={7}
onChange={(e) => { onChange={(e) => {
onChange(e?.currentTarget?.value); onChange(e?.currentTarget?.value);
}} }}
ref={ref} ref={ref}
value={parsedValue} value={parsedValue}
/> />
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}, },
); );
ColorPicker.displayName = "ColorPicker"; ColorPicker.displayName = "ColorPicker";

View file

@ -3,74 +3,74 @@ import Image from "next/image";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
export const ComeFunziona = () => { export const ComeFunziona = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const features = [ const features = [
{ {
image: "/come_funziona_ricerca.jpg", image: "/come_funziona_ricerca.jpg",
icon: <Search className="size-8" />, icon: <Search className="size-8" />,
title: t.come_funziona.features.ricerca.title, title: t.come_funziona.features.ricerca.title,
desc: t.come_funziona.features.ricerca.desc, desc: t.come_funziona.features.ricerca.desc,
}, },
{ {
image: "/come_funziona_aggiungi.jpg", image: "/come_funziona_aggiungi.jpg",
icon: <HeartPlus className="size-8" />, icon: <HeartPlus className="size-8" />,
title: t.come_funziona.features.aggiungi.title, title: t.come_funziona.features.aggiungi.title,
desc: t.come_funziona.features.aggiungi.desc, desc: t.come_funziona.features.aggiungi.desc,
}, },
{ {
image: "/come_funziona_meet.jpg", image: "/come_funziona_meet.jpg",
icon: <Handshake className="size-8" />, icon: <Handshake className="size-8" />,
title: t.come_funziona.features.contatta.title, title: t.come_funziona.features.contatta.title,
desc: t.come_funziona.features.contatta.desc, desc: t.come_funziona.features.contatta.desc,
}, },
{ {
image: "/come_funziona_confirm.jpg", image: "/come_funziona_confirm.jpg",
icon: <Home className="size-8" />, icon: <Home className="size-8" />,
title: t.come_funziona.features.conferma.title, title: t.come_funziona.features.conferma.title,
desc: t.come_funziona.features.conferma.desc, desc: t.come_funziona.features.conferma.desc,
}, },
{ {
image: "/come_funziona_contratto.jpg", image: "/come_funziona_contratto.jpg",
icon: <ScrollText className="size-8" />, icon: <ScrollText className="size-8" />,
title: t.come_funziona.features.firma.title, title: t.come_funziona.features.firma.title,
desc: t.come_funziona.features.firma.desc, desc: t.come_funziona.features.firma.desc,
}, },
]; ];
return ( return (
<div className="mx-auto w-full max-w-6xl py-2"> <div className="mx-auto w-full max-w-6xl py-2">
<h2 className="max-w-xl text-3xl font-semibold tracking-tight md:mx-auto md:text-center md:text-4xl md:leading-[3.5rem]"> <h2 className="max-w-xl text-3xl font-semibold tracking-tight md:mx-auto md:text-center md:text-4xl md:leading-[3.5rem]">
{t.come_funziona.title} {t.come_funziona.title}
</h2> </h2>
<div className="mx-auto mt-8 w-full space-y-12 md:mt-16"> <div className="mx-auto mt-8 w-full space-y-12 md:mt-16">
{features.map((feature, idx) => ( {features.map((feature, idx) => (
<div <div
key={idx} key={idx}
className="flex flex-col items-center gap-x-8 gap-y-6 lg:flex-row lg:odd:flex-row-reverse" className="flex flex-col items-center gap-x-8 gap-y-6 lg:flex-row lg:odd:flex-row-reverse"
> >
<div className="flex shrink-0 basis-1/2 justify-center"> <div className="flex shrink-0 basis-1/2 justify-center">
<Image <Image
className="border-border/50 aspect-[6/4] rounded-xl border" className="border-border/50 aspect-[6/4] rounded-xl border"
src={feature.image} src={feature.image}
width={400} width={400}
height={200} height={200}
alt={"img" + idx} alt={`img${idx}`}
/> />
</div> </div>
<div className="flex basis-1/2 flex-col"> <div className="flex basis-1/2 flex-col">
<div className="flex items-center gap-3 text-blue-600"> <div className="flex items-center gap-3 text-blue-600">
{feature.icon} {feature.icon}
<h4 className="my-3 text-3xl font-semibold tracking-tight"> <h4 className="my-3 text-3xl font-semibold tracking-tight">
{feature.title} {feature.title}
</h4> </h4>
</div> </div>
<p className="text-muted-foreground text-[17px]"> <p className="text-muted-foreground text-[17px]">
{feature.desc} {feature.desc}
</p> </p>
</div> </div>
</div> </div>
))} ))}
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,63 +1,63 @@
import { cn } from "~/lib/utils"; import type { ReactNode } from "react";
import { import {
AlertDialog, AlertDialog,
AlertDialogTrigger, AlertDialogAction,
AlertDialogContent, AlertDialogCancel,
AlertDialogHeader, AlertDialogContent,
AlertDialogTitle, AlertDialogDescription,
AlertDialogDescription, AlertDialogFooter,
AlertDialogFooter, AlertDialogHeader,
AlertDialogCancel, AlertDialogTitle,
AlertDialogAction, AlertDialogTrigger,
} from "~/components/ui/alert-dialog"; } from "~/components/ui/alert-dialog";
import { buttonVariants } from "~/components/ui/button"; import { buttonVariants } from "~/components/ui/button";
import type { ReactNode } from "react"; import { cn } from "~/lib/utils";
export const Confirm = ({ export const Confirm = ({
children, children,
title, title,
description, description,
onConfirm, onConfirm,
onCancel, onCancel,
open, open,
setOpen, setOpen,
onlyHedless, onlyHedless,
cancelText, cancelText,
confirmText, confirmText,
}: { }: {
children: ReactNode; children: ReactNode;
title: string; title: string;
description: string; description: string;
onConfirm: () => void; onConfirm: () => void;
onCancel?: () => void; onCancel?: () => void;
open?: boolean; open?: boolean;
setOpen?: (open: boolean) => void; setOpen?: (open: boolean) => void;
onlyHedless?: boolean; onlyHedless?: boolean;
cancelText?: string; cancelText?: string;
confirmText?: string; confirmText?: string;
}) => { }) => {
return ( return (
<AlertDialog open={open} onOpenChange={setOpen}> <AlertDialog open={open} onOpenChange={setOpen}>
{onlyHedless ? null : ( {onlyHedless ? null : (
<AlertDialogTrigger asChild>{children}</AlertDialogTrigger> <AlertDialogTrigger asChild>{children}</AlertDialogTrigger>
)} )}
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle> <AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{description}</AlertDialogDescription> <AlertDialogDescription>{description}</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel onClick={() => onCancel && onCancel()}> <AlertDialogCancel onClick={() => onCancel?.()}>
{cancelText || "Annulla"} {cancelText || "Annulla"}
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction <AlertDialogAction
className={cn(buttonVariants({ variant: "destructive" }))} className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => onConfirm()} onClick={() => onConfirm()}
> >
{confirmText || "Conferma"} {confirmText || "Conferma"}
</AlertDialogAction> </AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); );
}; };

View file

@ -1,63 +1,63 @@
import { Minus, Plus } from "lucide-react"; import { Minus, Plus } from "lucide-react";
import Input from "~/components/custom_ui/input"; import Input from "~/components/custom_ui/input";
export const Counter = ({ export const Counter = ({
value, value,
setValue, setValue,
name, name,
onlyPositive, onlyPositive,
disabled, disabled,
}: { }: {
value: number; value: number;
setValue: (value: number) => void; setValue: (value: number) => void;
name: string; name: string;
onlyPositive?: boolean; onlyPositive?: boolean;
disabled?: boolean; disabled?: boolean;
}) => { }) => {
const handleChange = (change: number) => { const handleChange = (change: number) => {
const newValue = value + change; const newValue = value + change;
if (onlyPositive && newValue < 0) return; if (onlyPositive && newValue < 0) return;
setValue(newValue); setValue(newValue);
}; };
return ( return (
<div className="w-[9.5rem]"> <div className="w-[9.5rem]">
<label htmlFor={name} className="sr-only"> <label htmlFor={name} className="sr-only">
{name} {name}
</label> </label>
<div className="flex items-center rounded-md border px-1"> <div className="flex items-center rounded-md border px-1">
<button <button
type="button" type="button"
onClick={() => handleChange(-1)} onClick={() => handleChange(-1)}
className="flex size-10 items-center justify-center leading-10 transition hover:opacity-75 disabled:opacity-50" className="flex size-10 items-center justify-center leading-10 transition hover:opacity-75 disabled:opacity-50"
disabled={disabled} disabled={disabled}
> >
<Minus /> <Minus />
</button> </button>
<Input <Input
type="number" type="number"
id={name} id={name}
value={value} value={value}
onChange={(e) => { onChange={(e) => {
if (!Number.isNaN(Number.parseInt(e.target.value))) { if (!Number.isNaN(Number.parseInt(e.target.value))) {
setValue(Number.parseInt(e.target.value)); setValue(Number.parseInt(e.target.value));
} else { } else {
e.target.value = String(value); e.target.value = String(value);
} }
}} }}
disabled={disabled} disabled={disabled}
className="no-spinner h-10 w-16 rounded-md border-none text-center shadow-none disabled:cursor-default sm:text-sm" className="no-spinner h-10 w-16 rounded-md border-none text-center shadow-none disabled:cursor-default sm:text-sm"
/> />
<button <button
type="button" type="button"
onClick={() => handleChange(1)} onClick={() => handleChange(1)}
className="flex size-10 items-center justify-center leading-10 transition hover:opacity-75 disabled:opacity-50" className="flex size-10 items-center justify-center leading-10 transition hover:opacity-75 disabled:opacity-50"
disabled={disabled} disabled={disabled}
> >
<Plus /> <Plus />
</button> </button>
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,71 +1,71 @@
"use client"; "use client";
import type {
PopoverContentProps,
PopoverProps,
PopoverTriggerProps,
} from "@radix-ui/react-popover";
import type {
TooltipContentProps,
TooltipProps,
TooltipTriggerProps,
} from "@radix-ui/react-tooltip";
import { import {
type PropsWithChildren, createContext,
createContext, type PropsWithChildren,
useContext, useContext,
useEffect, useEffect,
useState, useState,
} from "react"; } from "react";
import { import {
Tooltip, Popover,
TooltipTrigger, PopoverContent,
TooltipContent, PopoverTrigger,
} from "~/components/ui/tooltip";
import {
Popover,
PopoverTrigger,
PopoverContent,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import type { import {
TooltipContentProps, Tooltip,
TooltipProps, TooltipContent,
TooltipTriggerProps, TooltipTrigger,
} from "@radix-ui/react-tooltip"; } from "~/components/ui/tooltip";
import type {
PopoverContentProps,
PopoverProps,
PopoverTriggerProps,
} from "@radix-ui/react-popover";
const TouchContext = createContext<boolean | undefined>(undefined); const TouchContext = createContext<boolean | undefined>(undefined);
const useTouch = () => useContext(TouchContext); const useTouch = () => useContext(TouchContext);
export const TouchProvider = (props: PropsWithChildren) => { export const TouchProvider = (props: PropsWithChildren) => {
const [isTouch, setTouch] = useState<boolean>(); const [isTouch, setTouch] = useState<boolean>();
useEffect(() => { useEffect(() => {
setTouch(window.matchMedia("(pointer: coarse)").matches); setTouch(window.matchMedia("(pointer: coarse)").matches);
}, []); }, []);
return <TouchContext.Provider value={isTouch} {...props} />; return <TouchContext.Provider value={isTouch} {...props} />;
}; };
export const HybridTooltip = (props: TooltipProps & PopoverProps) => { export const HybridTooltip = (props: TooltipProps & PopoverProps) => {
const isTouch = useTouch(); const isTouch = useTouch();
return isTouch ? <Popover {...props} /> : <Tooltip {...props} />; return isTouch ? <Popover {...props} /> : <Tooltip {...props} />;
}; };
export const HybridTooltipTrigger = ( export const HybridTooltipTrigger = (
props: TooltipTriggerProps & PopoverTriggerProps, props: TooltipTriggerProps & PopoverTriggerProps,
) => { ) => {
const isTouch = useTouch(); const isTouch = useTouch();
return isTouch ? ( return isTouch ? (
<PopoverTrigger {...props} /> <PopoverTrigger {...props} />
) : ( ) : (
<TooltipTrigger {...props} /> <TooltipTrigger {...props} />
); );
}; };
export const HybridTooltipContent = ( export const HybridTooltipContent = (
props: TooltipContentProps & PopoverContentProps, props: TooltipContentProps & PopoverContentProps,
) => { ) => {
const isTouch = useTouch(); const isTouch = useTouch();
return isTouch ? ( return isTouch ? (
<PopoverContent {...props} /> <PopoverContent {...props} />
) : ( ) : (
<TooltipContent {...props} /> <TooltipContent {...props} />
); );
}; };

View file

@ -1,128 +1,127 @@
import { useAnimate, type AnimationSequence } from "framer-motion"; import { type AnimationSequence, useAnimate } from "framer-motion";
import { Button } from "~/components/ui/button";
import { IconMatrix, type IconType } from "~/components/IconComponents";
import { cn } from "~/lib/utils";
import { import {
forwardRef, type ComponentPropsWithoutRef,
useImperativeHandle, forwardRef,
type ComponentPropsWithoutRef, useImperativeHandle,
} from "react"; } from "react";
import { IconMatrix, type IconType } from "~/components/IconComponents";
import { Button } from "~/components/ui/button";
import { cn } from "~/lib/utils";
const randomNumberBetween = (min: number, max: number) => { const randomNumberBetween = (min: number, max: number) => {
return Math.floor(Math.random() * (max - min + 1) + min); return Math.floor(Math.random() * (max - min + 1) + min);
}; };
export interface AnimatedButtonRef { export interface AnimatedButtonRef {
sparkle: () => void; sparkle: () => void;
} }
interface AnimatedButtonProps extends ComponentPropsWithoutRef<typeof Button> { interface AnimatedButtonProps extends ComponentPropsWithoutRef<typeof Button> {
sparklesRadius?: number; sparklesRadius?: number;
sparklesNumber?: number; sparklesNumber?: number;
sparklesClassName?: string; sparklesClassName?: string;
sparklesIcon?: IconType; sparklesIcon?: IconType;
sparklesScale?: [number, number]; sparklesScale?: [number, number];
} }
export const AnimatedButton = forwardRef< export const AnimatedButton = forwardRef<
AnimatedButtonRef, AnimatedButtonRef,
AnimatedButtonProps AnimatedButtonProps
>( >(
( (
{ {
sparklesRadius = 100, sparklesRadius = 100,
sparklesNumber = 20, sparklesNumber = 20,
sparklesClassName = "fill-blue-500 stroke-transparent", sparklesClassName = "fill-blue-500 stroke-transparent",
sparklesIcon = "star", sparklesIcon = "star",
sparklesScale = [1.5, 2.5], sparklesScale = [1.5, 2.5],
...props ...props
}, },
ref, ref,
) => { ) => {
const [scope, animate] = useAnimate(); const [scope, animate] = useAnimate();
const Sparkles = () => { const Sparkles = () => {
const sparkles = Array.from({ length: sparklesNumber }); const sparkles = Array.from({ length: sparklesNumber });
const sparklesAnimation: AnimationSequence = sparkles.map((_, index) => [ const sparklesAnimation: AnimationSequence = sparkles.map((_, index) => [
`.sparkle-${index}`, `.sparkle-${index}`,
{ {
x: randomNumberBetween(-sparklesRadius, sparklesRadius), x: randomNumberBetween(-sparklesRadius, sparklesRadius),
y: randomNumberBetween(-sparklesRadius, sparklesRadius), y: randomNumberBetween(-sparklesRadius, sparklesRadius),
scale: randomNumberBetween(sparklesScale[0], sparklesScale[1]), scale: randomNumberBetween(sparklesScale[0], sparklesScale[1]),
opacity: 1, opacity: 1,
}, },
{ {
duration: 0.4, duration: 0.4,
at: "<", at: "<",
delay: index * 0.01, delay: index * 0.01,
}, },
]); ]);
const sparklesFadeOut: AnimationSequence = sparkles.map((_, index) => [ const sparklesFadeOut: AnimationSequence = sparkles.map((_, index) => [
`.sparkle-${index}`, `.sparkle-${index}`,
{ {
opacity: 0, opacity: 0,
scale: 0, scale: 0,
}, },
{ {
duration: 0.3, duration: 0.3,
at: "<", at: "<",
}, },
]); ]);
const sparklesReset: AnimationSequence = sparkles.map((_, index) => [ const sparklesReset: AnimationSequence = sparkles.map((_, index) => [
`.sparkle-${index}`, `.sparkle-${index}`,
{ {
x: 0, x: 0,
y: 0, y: 0,
}, },
{ {
duration: 0.000001, duration: 0.000001,
}, },
]); ]);
animate([ animate([
...sparklesReset, ...sparklesReset,
["button", { scale: 0.8 }, { duration: 0.1, at: "<" }], ["button", { scale: 0.8 }, { duration: 0.1, at: "<" }],
["button", { scale: 1 }, { duration: 0.1 }], ["button", { scale: 1 }, { duration: 0.1 }],
...sparklesAnimation, ...sparklesAnimation,
["button", { duration: 0.000001 }], ["button", { duration: 0.000001 }],
...sparklesFadeOut, ...sparklesFadeOut,
]); ]);
}; };
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
sparkle: Sparkles, sparkle: Sparkles,
})); }));
return ( return (
<div ref={scope} className="w-full"> <div ref={scope} className="w-full">
<Button <Button
{...props} {...props}
className={cn("relative", props.className)} className={cn("relative", props.className)}
aria-label="Animated Button" aria-label="Animated Button"
> >
{props.children} {props.children}
<span <span
aria-hidden aria-hidden
className="pointer-events-none absolute inset-0 z-10 block" className="pointer-events-none absolute inset-0 z-10 block"
> >
{Array.from({ length: sparklesNumber }).map((_, index) => ( {Array.from({ length: sparklesNumber }).map((_, index) => (
<IconMatrix <IconMatrix
type={sparklesIcon} type={sparklesIcon}
className={cn( className={cn(
// eslint-disable-next-line better-tailwindcss/no-unregistered-classes `absolute top-1/2 left-1/2 opacity-0 sparkle-${index}`,
`absolute top-1/2 left-1/2 opacity-0 sparkle-${index}`, sparklesClassName,
sparklesClassName, )}
)} key={index}
key={index} />
/> ))}
))} </span>
</span> </Button>
</Button> </div>
</div> );
); },
},
); );
AnimatedButton.displayName = "AnimatedButton"; AnimatedButton.displayName = "AnimatedButton";

View file

@ -1,55 +1,56 @@
"use client"; "use client";
import { useEffect } from "react";
import { import {
type MotionValue, type MotionValue,
motion, motion,
useSpring, useSpring,
useTransform, useTransform,
} from "framer-motion"; } from "framer-motion";
import { useEffect } from "react";
//DOC: https://www.cult-ui.com/docs/components/animated-number //DOC: https://www.cult-ui.com/docs/components/animated-number
interface AnimatedNumberProps { interface AnimatedNumberProps {
value: number; value: number;
mass?: number; mass?: number;
stiffness?: number; stiffness?: number;
damping?: number; damping?: number;
precision?: number; precision?: number;
format?: (value: number) => string; format?: (value: number) => string;
onAnimationStart?: () => void; onAnimationStart?: () => void;
onAnimationComplete?: () => void; onAnimationComplete?: () => void;
} }
export function AnimatedNumber({ export function AnimatedNumber({
value, value,
mass = 0.8, mass = 0.8,
stiffness = 75, stiffness = 75,
damping = 15, damping = 15,
precision = 0, precision = 0,
format, format,
onAnimationStart, onAnimationStart,
onAnimationComplete, onAnimationComplete,
}: AnimatedNumberProps) { }: AnimatedNumberProps) {
const spring = useSpring(value, { const spring = useSpring(value, {
mass, mass,
stiffness, stiffness,
damping, damping,
}); });
const display: MotionValue<string> = useTransform(spring, (current) => const display: MotionValue<string> = useTransform(spring, (current) =>
format format
? format(parseFloat(current.toFixed(precision))) ? format(parseFloat(current.toFixed(precision)))
: parseFloat(current.toFixed(precision)).toLocaleString(), : parseFloat(current.toFixed(precision)).toLocaleString(),
); );
useEffect(() => { useEffect(() => {
spring.set(value); spring.set(value);
if (onAnimationStart) onAnimationStart(); if (onAnimationStart) onAnimationStart();
const unsubscribe = spring.on("change", () => { const unsubscribe = spring.on("change", () => {
if (spring.get() === value && onAnimationComplete) onAnimationComplete(); if (spring.get() === value && onAnimationComplete) onAnimationComplete();
}); });
return () => unsubscribe(); return () => unsubscribe();
}, [spring, value, onAnimationStart, onAnimationComplete]); }, [spring, value, onAnimationStart, onAnimationComplete]);
return <motion.span>{display}</motion.span>; return <motion.span>{display}</motion.span>;
} }

View file

@ -1,116 +1,117 @@
import { cn } from "~/lib/utils";
import { import {
forwardRef, forwardRef,
useEffect, type Ref,
useImperativeHandle, type TextareaHTMLAttributes,
useRef, useEffect,
useState, useImperativeHandle,
type Ref, useRef,
type TextareaHTMLAttributes, useState,
} from "react"; } from "react";
import { cn } from "~/lib/utils";
interface UseAutosizeTextAreaProps { interface UseAutosizeTextAreaProps {
textAreaRef: HTMLTextAreaElement | null; textAreaRef: HTMLTextAreaElement | null;
minHeight?: number; minHeight?: number;
maxHeight?: number; maxHeight?: number;
triggerAutoSize: string; triggerAutoSize: string;
} }
const useAutosizeTextArea = ({ const useAutosizeTextArea = ({
textAreaRef, textAreaRef,
triggerAutoSize, triggerAutoSize,
maxHeight = Number.MAX_SAFE_INTEGER, maxHeight = Number.MAX_SAFE_INTEGER,
minHeight = 0, minHeight = 0,
}: UseAutosizeTextAreaProps) => { }: UseAutosizeTextAreaProps) => {
const [init, setInit] = useState(true); const [init, setInit] = useState(true);
useEffect(() => { useEffect(() => {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea // We need to reset the height momentarily to get the correct scrollHeight for the textarea
const offsetBorder = 2; const offsetBorder = 2;
if (textAreaRef) { if (textAreaRef) {
if (init) { if (init) {
textAreaRef.style.minHeight = `${minHeight + offsetBorder}px`; textAreaRef.style.minHeight = `${minHeight + offsetBorder}px`;
if (maxHeight > minHeight) { if (maxHeight > minHeight) {
textAreaRef.style.maxHeight = `${maxHeight}px`; textAreaRef.style.maxHeight = `${maxHeight}px`;
} }
setInit(false); setInit(false);
} }
textAreaRef.style.height = `${minHeight + offsetBorder}px`; textAreaRef.style.height = `${minHeight + offsetBorder}px`;
const scrollHeight = textAreaRef.scrollHeight; const scrollHeight = textAreaRef.scrollHeight;
// We then set the height directly, outside of the render loop // We then set the height directly, outside of the render loop
// Trying to set this with state or a ref will product an incorrect value. // Trying to set this with state or a ref will product an incorrect value.
if (scrollHeight > maxHeight) { if (scrollHeight > maxHeight) {
textAreaRef.style.height = `${maxHeight}px`; textAreaRef.style.height = `${maxHeight}px`;
} else { } else {
textAreaRef.style.height = `${scrollHeight + offsetBorder}px`; textAreaRef.style.height = `${scrollHeight + offsetBorder}px`;
} }
} }
}, [textAreaRef, triggerAutoSize]); }, [textAreaRef, triggerAutoSize]);
}; };
export type AutosizeTextAreaRef = { export type AutosizeTextAreaRef = {
textArea: HTMLTextAreaElement; textArea: HTMLTextAreaElement;
maxHeight: number; maxHeight: number;
minHeight: number; minHeight: number;
}; };
type AutosizeTextAreaProps = { type AutosizeTextAreaProps = {
maxHeight?: number; maxHeight?: number;
minHeight?: number; minHeight?: number;
} & TextareaHTMLAttributes<HTMLTextAreaElement>; } & TextareaHTMLAttributes<HTMLTextAreaElement>;
export const AutosizeTextarea = forwardRef< export const AutosizeTextarea = forwardRef<
AutosizeTextAreaRef, AutosizeTextAreaRef,
AutosizeTextAreaProps AutosizeTextAreaProps
>( >(
( (
{ {
maxHeight = Number.MAX_SAFE_INTEGER, maxHeight = Number.MAX_SAFE_INTEGER,
minHeight = 56, minHeight = 56,
className, className,
onChange, onChange,
value, value,
...props ...props
}: AutosizeTextAreaProps, }: AutosizeTextAreaProps,
ref: Ref<AutosizeTextAreaRef>, ref: Ref<AutosizeTextAreaRef>,
) => { ) => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null); const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [triggerAutoSize, setTriggerAutoSize] = useState(""); const [triggerAutoSize, setTriggerAutoSize] = useState("");
useAutosizeTextArea({ useAutosizeTextArea({
textAreaRef: textAreaRef.current, textAreaRef: textAreaRef.current,
triggerAutoSize: triggerAutoSize, triggerAutoSize: triggerAutoSize,
maxHeight, maxHeight,
minHeight, minHeight,
}); });
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({
textArea: textAreaRef.current!, // biome-ignore lint/style/noNonNullAssertion: <it exists>
focus: () => textAreaRef.current?.focus(), textArea: textAreaRef.current!,
maxHeight, focus: () => textAreaRef.current?.focus(),
minHeight, maxHeight,
})); minHeight,
}));
useEffect(() => { useEffect(() => {
if (value || props?.defaultValue) { if (value || props?.defaultValue) {
setTriggerAutoSize(value as string); setTriggerAutoSize(value as string);
} }
}, [value || props?.defaultValue]); }, [value || props?.defaultValue]);
return ( return (
<textarea <textarea
{...props} {...props}
value={value} value={value}
ref={textAreaRef} ref={textAreaRef}
className={cn( className={cn(
"border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50", "border-input bg-background ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex w-full rounded-md border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
onChange={(e) => { onChange={(e) => {
setTriggerAutoSize(e.target.value); setTriggerAutoSize(e.target.value);
onChange?.(e); onChange?.(e);
}} }}
/> />
); );
}, },
); );
AutosizeTextarea.displayName = "AutosizeTextarea"; AutosizeTextarea.displayName = "AutosizeTextarea";

View file

@ -1,57 +1,57 @@
"use client"; "use client";
import { useMediaQuery } from "~/hooks/use-media-query"; import { createContext, type ReactNode, useContext } from "react";
import type { Drawer as DrawerPrimitive } from "vaul";
import { import {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from "~/components/ui/dialog"; } from "~/components/ui/dialog";
import { import {
Drawer, Drawer,
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
DrawerDescription, DrawerDescription,
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
//DrawerNested, //DrawerNested,
DrawerTitle, DrawerTitle,
DrawerTrigger, DrawerTrigger,
} from "~/components/ui/drawer"; } from "~/components/ui/drawer";
import { type Drawer as DrawerPrimitive } from "vaul"; import { useMediaQuery } from "~/hooks/use-media-query";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { createContext, useContext, type ReactNode } from "react";
interface BaseProps { interface BaseProps {
children: ReactNode; children: ReactNode;
} }
interface RootCredenzaProps extends BaseProps { interface RootCredenzaProps extends BaseProps {
open?: boolean; open?: boolean;
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
drawerProps?: React.ComponentProps<typeof DrawerPrimitive.Root>; drawerProps?: React.ComponentProps<typeof DrawerPrimitive.Root>;
} }
interface CredenzaProps extends BaseProps { interface CredenzaProps extends BaseProps {
className?: string; className?: string;
asChild?: true; asChild?: true;
} }
const CredenzaContext = createContext<{ isDesktop: boolean }>({ const CredenzaContext = createContext<{ isDesktop: boolean }>({
isDesktop: false, isDesktop: false,
}); });
const useCredenzaContext = () => { const useCredenzaContext = () => {
const context = useContext(CredenzaContext); const context = useContext(CredenzaContext);
if (!context) { if (!context) {
throw new Error( throw new Error(
"Credenza components cannot be rendered outside the Credenza Context", "Credenza components cannot be rendered outside the Credenza Context",
); );
} }
return context; return context;
}; };
/** /**
* Drawer-Dialog component * Drawer-Dialog component
@ -91,19 +91,19 @@ const useCredenzaContext = () => {
* </Credenza> * </Credenza>
*/ */
const Credenza = ({ children, drawerProps, ...props }: RootCredenzaProps) => { const Credenza = ({ children, drawerProps, ...props }: RootCredenzaProps) => {
const isDesktop = useMediaQuery("(min-width: 768px)"); const isDesktop = useMediaQuery("(min-width: 768px)");
const Credenza = isDesktop ? Dialog : Drawer; const Credenza = isDesktop ? Dialog : Drawer;
return ( return (
<CredenzaContext.Provider value={{ isDesktop }}> <CredenzaContext.Provider value={{ isDesktop }}>
<Credenza <Credenza
{...props} {...props}
{...(!isDesktop && { ...drawerProps, autoFocus: true })} {...(!isDesktop && { ...drawerProps, autoFocus: true })}
> >
{children} {children}
</Credenza> </Credenza>
</CredenzaContext.Provider> </CredenzaContext.Provider>
); );
}; };
/*const CredenzaNested = ({ /*const CredenzaNested = ({
@ -127,103 +127,103 @@ const Credenza = ({ children, drawerProps, ...props }: RootCredenzaProps) => {
};*/ };*/
const CredenzaTrigger = ({ className, children, ...props }: CredenzaProps) => { const CredenzaTrigger = ({ className, children, ...props }: CredenzaProps) => {
const { isDesktop } = useCredenzaContext(); const { isDesktop } = useCredenzaContext();
const CredenzaTrigger = isDesktop ? DialogTrigger : DrawerTrigger; const CredenzaTrigger = isDesktop ? DialogTrigger : DrawerTrigger;
return ( return (
<CredenzaTrigger className={className} {...props}> <CredenzaTrigger className={className} {...props}>
{children} {children}
</CredenzaTrigger> </CredenzaTrigger>
); );
}; };
const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => { const CredenzaClose = ({ className, children, ...props }: CredenzaProps) => {
const { isDesktop } = useCredenzaContext(); const { isDesktop } = useCredenzaContext();
const CredenzaClose = isDesktop ? DialogClose : DrawerClose; const CredenzaClose = isDesktop ? DialogClose : DrawerClose;
return ( return (
<CredenzaClose className={className} {...props}> <CredenzaClose className={className} {...props}>
{children} {children}
</CredenzaClose> </CredenzaClose>
); );
}; };
const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => { const CredenzaContent = ({ className, children, ...props }: CredenzaProps) => {
const { isDesktop } = useCredenzaContext(); const { isDesktop } = useCredenzaContext();
const CredenzaContent = isDesktop ? DialogContent : DrawerContent; const CredenzaContent = isDesktop ? DialogContent : DrawerContent;
return ( return (
<CredenzaContent className={className} {...props}> <CredenzaContent className={className} {...props}>
{children} {children}
</CredenzaContent> </CredenzaContent>
); );
}; };
const CredenzaDescription = ({ const CredenzaDescription = ({
className, className,
children, children,
...props ...props
}: CredenzaProps) => { }: CredenzaProps) => {
const { isDesktop } = useCredenzaContext(); const { isDesktop } = useCredenzaContext();
const CredenzaDescription = isDesktop ? DialogDescription : DrawerDescription; const CredenzaDescription = isDesktop ? DialogDescription : DrawerDescription;
return ( return (
<CredenzaDescription className={className} {...props}> <CredenzaDescription className={className} {...props}>
{children} {children}
</CredenzaDescription> </CredenzaDescription>
); );
}; };
const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => { const CredenzaHeader = ({ className, children, ...props }: CredenzaProps) => {
const { isDesktop } = useCredenzaContext(); const { isDesktop } = useCredenzaContext();
const CredenzaHeader = isDesktop ? DialogHeader : DrawerHeader; const CredenzaHeader = isDesktop ? DialogHeader : DrawerHeader;
return ( return (
<CredenzaHeader className={className} {...props}> <CredenzaHeader className={className} {...props}>
{children} {children}
</CredenzaHeader> </CredenzaHeader>
); );
}; };
const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => { const CredenzaTitle = ({ className, children, ...props }: CredenzaProps) => {
const { isDesktop } = useCredenzaContext(); const { isDesktop } = useCredenzaContext();
const CredenzaTitle = isDesktop ? DialogTitle : DrawerTitle; const CredenzaTitle = isDesktop ? DialogTitle : DrawerTitle;
return ( return (
<CredenzaTitle className={className} {...props}> <CredenzaTitle className={className} {...props}>
{children} {children}
</CredenzaTitle> </CredenzaTitle>
); );
}; };
const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => { const CredenzaBody = ({ className, children, ...props }: CredenzaProps) => {
return ( return (
<div className={cn("px-4 md:px-0", className)} {...props}> <div className={cn("px-4 md:px-0", className)} {...props}>
{children} {children}
</div> </div>
); );
}; };
const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => { const CredenzaFooter = ({ className, children, ...props }: CredenzaProps) => {
const { isDesktop } = useCredenzaContext(); const { isDesktop } = useCredenzaContext();
const CredenzaFooter = isDesktop ? DialogFooter : DrawerFooter; const CredenzaFooter = isDesktop ? DialogFooter : DrawerFooter;
return ( return (
<CredenzaFooter className={className} {...props}> <CredenzaFooter className={className} {...props}>
{children} {children}
</CredenzaFooter> </CredenzaFooter>
); );
}; };
export { export {
Credenza, Credenza,
//CredenzaNested, //CredenzaNested,
CredenzaTrigger, CredenzaTrigger,
CredenzaClose, CredenzaClose,
CredenzaContent, CredenzaContent,
CredenzaDescription, CredenzaDescription,
CredenzaHeader, CredenzaHeader,
CredenzaTitle, CredenzaTitle,
CredenzaBody, CredenzaBody,
CredenzaFooter, CredenzaFooter,
}; };

View file

@ -1,207 +1,208 @@
import { import {
type ColumnDef, type ColumnDef,
useReactTable, type ColumnFiltersState,
getCoreRowModel, type ColumnSort,
flexRender, flexRender,
getSortedRowModel, getCoreRowModel,
type VisibilityState, getFilteredRowModel,
type ColumnFiltersState, getPaginationRowModel,
getFilteredRowModel, getSortedRowModel,
type SortingState, type RowSelectionState,
getPaginationRowModel, type SortingState,
type ColumnSort, type Table as TableType,
type RowSelectionState, useReactTable,
type Table as TableType, type VisibilityState,
} from "@tanstack/react-table"; } from "@tanstack/react-table";
import { import { useVirtualizer } from "@tanstack/react-virtual";
Table, import { useEffect, useRef, useState } from "react";
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { DataTablePagination } from "~/components/custom_ui/dataTable-pagination"; import { DataTablePagination } from "~/components/custom_ui/dataTable-pagination";
import { import {
DataTableToolbar, DataTableToolbar,
type SearchFiltro, type PinnedFiltro,
type PinnedFiltro, type SearchFiltro,
} from "~/components/custom_ui/dataTable-toolbar"; } from "~/components/custom_ui/dataTable-toolbar";
import { useVirtualizer } from "@tanstack/react-virtual"; import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "~/components/ui/table";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { useEffect, useRef, useState } from "react";
interface DataTableProps<TData, TValue> { interface DataTableProps<TData, TValue> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
data: TData[]; data: TData[];
pinnedFiltri?: PinnedFiltro[]; pinnedFiltri?: PinnedFiltro[];
hasSelectRow?: boolean; hasSelectRow?: boolean;
searchColumn?: SearchFiltro; searchColumn?: SearchFiltro;
columns_titles: Record<string, string>; columns_titles: Record<string, string>;
defaultSort?: ColumnSort[]; defaultSort?: ColumnSort[];
rowOnClick?: (data: TData, rowId: string) => void; rowOnClick?: (data: TData, rowId: string) => void;
highlightedRow?: string; highlightedRow?: string;
defaultColumnVisibility?: VisibilityState; defaultColumnVisibility?: VisibilityState;
tabClassName?: string; tabClassName?: string;
pagination?: boolean; pagination?: boolean;
onStateChange?: () => void; onStateChange?: () => void;
defaultFilterState?: ColumnFiltersState; defaultFilterState?: ColumnFiltersState;
onTableInit?: (table: TableType<TData>) => void; onTableInit?: (table: TableType<TData>) => void;
} }
export function DataTable<TData, TValue>({ export function DataTable<TData, TValue>({
columns, columns,
data, data,
pinnedFiltri, pinnedFiltri,
hasSelectRow, hasSelectRow,
searchColumn, searchColumn,
columns_titles, columns_titles,
defaultSort, defaultSort,
rowOnClick, rowOnClick,
highlightedRow, highlightedRow,
defaultColumnVisibility, defaultColumnVisibility,
tabClassName, tabClassName,
pagination = true, pagination = true,
onStateChange, onStateChange,
defaultFilterState, defaultFilterState,
onTableInit, onTableInit,
}: DataTableProps<TData, TValue>) { }: DataTableProps<TData, TValue>) {
const [sorting, setSorting] = useState<SortingState>(defaultSort || []); const [sorting, setSorting] = useState<SortingState>(defaultSort || []);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({}); const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>( const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>(
defaultFilterState || [], defaultFilterState || [],
); );
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>( const [columnVisibility, setColumnVisibility] = useState<VisibilityState>(
defaultColumnVisibility || {}, defaultColumnVisibility || {},
); );
const [globalFilter, setGlobalFilter] = useState<string>(""); const [globalFilter, setGlobalFilter] = useState<string>("");
useEffect(() => { useEffect(() => {
onStateChange?.(); onStateChange?.();
}, [rowSelection, columnFilters, columnVisibility, sorting, onStateChange]); }, [rowSelection, columnFilters, columnVisibility, sorting, onStateChange]);
const table = useReactTable({ const table = useReactTable({
data, data,
columns, columns,
onRowSelectionChange: setRowSelection, onRowSelectionChange: setRowSelection,
getCoreRowModel: getCoreRowModel(), getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel(), getPaginationRowModel: getPaginationRowModel(),
onSortingChange: setSorting, onSortingChange: setSorting,
getSortedRowModel: getSortedRowModel(), getSortedRowModel: getSortedRowModel(),
onColumnFiltersChange: setColumnFilters, onColumnFiltersChange: setColumnFilters,
getFilteredRowModel: getFilteredRowModel(), getFilteredRowModel: getFilteredRowModel(),
onColumnVisibilityChange: setColumnVisibility, onColumnVisibilityChange: setColumnVisibility,
onGlobalFilterChange: setGlobalFilter, onGlobalFilterChange: setGlobalFilter,
state: { state: {
globalFilter, globalFilter,
sorting, sorting,
columnFilters, columnFilters,
columnVisibility, columnVisibility,
rowSelection, rowSelection,
}, },
}); });
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const { rows } = table.getRowModel(); const { rows } = table.getRowModel();
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: rows.length, count: rows.length,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 34, estimateSize: () => 34,
overscan: 20, overscan: 20,
}); });
useEffect(() => { useEffect(() => {
if (table && onTableInit) { if (table && onTableInit) {
onTableInit(table); onTableInit(table);
} }
}, [table, onTableInit]); }, [table, onTableInit]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<DataTableToolbar <DataTableToolbar
table={table} table={table}
pinnedColumns={pinnedFiltri} pinnedColumns={pinnedFiltri}
searchColumn={searchColumn} searchColumn={searchColumn}
columns_titles={columns_titles} columns_titles={columns_titles}
globalFilter={globalFilter} globalFilter={globalFilter}
/> />
<div className="rounded-md border" ref={parentRef}> <div className="rounded-md border" ref={parentRef}>
<Table> <Table>
<TableHeader> <TableHeader>
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}> <TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => { {headerGroup.headers.map((header) => {
return ( return (
<TableHead key={header.id}> <TableHead key={header.id}>
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header.column.columnDef.header, header.column.columnDef.header,
header.getContext(), header.getContext(),
)} )}
</TableHead> </TableHead>
); );
})} })}
</TableRow> </TableRow>
))} ))}
</TableHeader> </TableHeader>
<TableBody className={tabClassName}> <TableBody className={tabClassName}>
{virtualizer.getVirtualItems().length ? ( {virtualizer.getVirtualItems().length ? (
virtualizer.getVirtualItems().map((virtualRow, index) => { virtualizer.getVirtualItems().map((virtualRow, index) => {
const row = rows[virtualRow.index]!; // biome-ignore lint/style/noNonNullAssertion: <always exists>
return ( const row = rows[virtualRow.index]!;
<TableRow return (
onClick={ <TableRow
rowOnClick onClick={
? () => rowOnClick(row.original, row.id) rowOnClick
: undefined ? () => rowOnClick(row.original, row.id)
} : undefined
key={row.id} }
data-state={row.getIsSelected() && "selected"} key={row.id}
data-row-id={row.id} data-state={row.getIsSelected() && "selected"}
style={{ data-row-id={row.id}
height: `${virtualRow.size}px`, style={{
transform: `translateY(${ height: `${virtualRow.size}px`,
virtualRow.start - index * virtualRow.size transform: `translateY(${
}px)`, virtualRow.start - index * virtualRow.size
}} }px)`,
className={cn( }}
rowOnClick && "cursor-pointer", className={cn(
highlightedRow === row.id && "bg-neutral-100/50", rowOnClick && "cursor-pointer",
"group/row", highlightedRow === row.id && "bg-neutral-100/50",
)} "group/row",
> )}
{row.getVisibleCells().map((cell) => { >
return ( {row.getVisibleCells().map((cell) => {
<TableCell key={cell.id}> return (
{flexRender( <TableCell key={cell.id}>
cell.column.columnDef.cell, {flexRender(
cell.getContext(), cell.column.columnDef.cell,
)} cell.getContext(),
</TableCell> )}
); </TableCell>
})} );
</TableRow> })}
); </TableRow>
}) );
) : ( })
<TableRow> ) : (
<TableCell <TableRow>
colSpan={columns.length} <TableCell
className="h-24 text-center" colSpan={columns.length}
> className="h-24 text-center"
No results. >
</TableCell> No results.
</TableRow> </TableCell>
)} </TableRow>
</TableBody> )}
</Table> </TableBody>
</div> </Table>
{pagination ? ( </div>
<DataTablePagination table={table} hasSelectRow={hasSelectRow} /> {pagination ? (
) : null} <DataTablePagination table={table} hasSelectRow={hasSelectRow} />
</div> ) : null}
); </div>
);
} }

View file

@ -1,146 +1,146 @@
import type { Column } from "@tanstack/react-table"; import type { Column } from "@tanstack/react-table";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Button } from "~/components/ui/button";
import { CheckIcon, CirclePlus } from "lucide-react"; import { CheckIcon, CirclePlus } from "lucide-react";
import { Separator } from "~/components/ui/separator"; import type { ComponentType } from "react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
CommandList, CommandList,
CommandSeparator, CommandSeparator,
} from "~/components/ui/command"; } from "~/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { Separator } from "~/components/ui/separator";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { type ComponentType } from "react";
interface DataTableFacetedFilterProps<TData, TValue> { interface DataTableFacetedFilterProps<TData, TValue> {
column?: Column<TData, TValue>; column?: Column<TData, TValue>;
title?: string; title?: string;
options: { options: {
label: string; label: string;
value: string; value: string;
icon?: ComponentType<{ className?: string }>; icon?: ComponentType<{ className?: string }>;
}[]; }[];
} }
export function DataTableFacetedFilter<TData, TValue>({ export function DataTableFacetedFilter<TData, TValue>({
column, column,
title, title,
options, options,
}: DataTableFacetedFilterProps<TData, TValue>) { }: DataTableFacetedFilterProps<TData, TValue>) {
const facets = column?.getFacetedUniqueValues(); const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]); const selectedValues = new Set(column?.getFilterValue() as string[]);
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="outline" size="sm" className="h-8 border-dashed"> <Button variant="outline" size="sm" className="h-8 border-dashed">
<CirclePlus className="mr-2 size-4" /> <CirclePlus className="mr-2 size-4" />
{title} {title}
{selectedValues?.size > 0 && ( {selectedValues?.size > 0 && (
<> <>
<Separator orientation="vertical" className="mx-2 h-4" /> <Separator orientation="vertical" className="mx-2 h-4" />
<Badge <Badge
variant="secondary" variant="secondary"
className="rounded-xs px-1 font-normal lg:hidden" className="rounded-xs px-1 font-normal lg:hidden"
> >
{selectedValues.size} {selectedValues.size}
</Badge> </Badge>
<div className="hidden space-x-1 lg:flex"> <div className="hidden space-x-1 lg:flex">
{selectedValues.size > 2 ? ( {selectedValues.size > 2 ? (
<Badge <Badge
variant="secondary" variant="secondary"
className="rounded-xs px-1 font-normal" className="rounded-xs px-1 font-normal"
> >
{selectedValues.size} selected {selectedValues.size} selected
</Badge> </Badge>
) : ( ) : (
options options
.filter((option) => selectedValues.has(option.value)) .filter((option) => selectedValues.has(option.value))
.map((option) => ( .map((option) => (
<Badge <Badge
variant="secondary" variant="secondary"
key={option.value} key={option.value}
className="rounded-xs px-1 font-normal" className="rounded-xs px-1 font-normal"
> >
{option.label} {option.label}
</Badge> </Badge>
)) ))
)} )}
</div> </div>
</> </>
)} )}
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start"> <PopoverContent className="w-[200px] p-0" align="start">
<Command> <Command>
<CommandInput placeholder={title} /> <CommandInput placeholder={title} />
<CommandList> <CommandList>
<CommandEmpty>Nessun risultato.</CommandEmpty> <CommandEmpty>Nessun risultato.</CommandEmpty>
<CommandGroup> <CommandGroup>
{options.map((option) => { {options.map((option) => {
const isSelected = selectedValues.has(option.value); const isSelected = selectedValues.has(option.value);
return ( return (
<CommandItem <CommandItem
key={option.value} key={option.value}
onSelect={() => { onSelect={() => {
if (isSelected) { if (isSelected) {
selectedValues.delete(option.value); selectedValues.delete(option.value);
} else { } else {
selectedValues.add(option.value); selectedValues.add(option.value);
} }
const filterValues = Array.from(selectedValues); const filterValues = Array.from(selectedValues);
column?.setFilterValue( column?.setFilterValue(
filterValues.length ? filterValues : undefined, filterValues.length ? filterValues : undefined,
); );
}} }}
> >
<div <div
className={cn( className={cn(
"border-primary mr-2 flex size-4 items-center justify-center rounded-xs border", "border-primary mr-2 flex size-4 items-center justify-center rounded-xs border",
isSelected isSelected
? "bg-primary text-primary-foreground" ? "bg-primary text-primary-foreground"
: "opacity-50 [&_svg]:invisible", : "opacity-50 [&_svg]:invisible",
)} )}
> >
<CheckIcon className={cn("size-4")} /> <CheckIcon className={cn("size-4")} />
</div> </div>
{option.icon && ( {option.icon && (
<option.icon className="text-muted-foreground mr-2 size-4" /> <option.icon className="text-muted-foreground mr-2 size-4" />
)} )}
<span>{option.label}</span> <span>{option.label}</span>
{facets?.get(option.value) && ( {facets?.get(option.value) && (
<span className="ml-auto flex size-4 items-center justify-center font-mono text-xs"> <span className="ml-auto flex size-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)} {facets.get(option.value)}
</span> </span>
)} )}
</CommandItem> </CommandItem>
); );
})} })}
</CommandGroup> </CommandGroup>
{selectedValues.size > 0 && ( {selectedValues.size > 0 && (
<> <>
<CommandSeparator /> <CommandSeparator />
<CommandGroup> <CommandGroup>
<CommandItem <CommandItem
onSelect={() => column?.setFilterValue(undefined)} onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center" className="justify-center text-center"
> >
Reset filtri Reset filtri
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
</> </>
)} )}
</CommandList> </CommandList>
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
} }

View file

@ -1,59 +1,60 @@
import type { Table } from "@tanstack/react-table"; import type { Table } from "@tanstack/react-table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Button } from "~/components/ui/button";
import { Filter } from "lucide-react"; import { Filter } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
interface DataTableViewOptionsProps<TData> { interface DataTableViewOptionsProps<TData> {
table: Table<TData>; table: Table<TData>;
columns_titles: Record<string, string>; columns_titles: Record<string, string>;
} }
export function DataTableViewOptions<TData>({ export function DataTableViewOptions<TData>({
table, table,
columns_titles, columns_titles,
}: DataTableViewOptionsProps<TData>) { }: DataTableViewOptionsProps<TData>) {
const hidableColumns = table const hidableColumns = table
.getAllColumns() .getAllColumns()
.filter( .filter(
(column) => (column) =>
typeof column.accessorFn !== "undefined" && column.getCanHide(), typeof column.accessorFn !== "undefined" && column.getCanHide(),
); );
if (hidableColumns.length > 0) if (hidableColumns.length > 0)
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="ml-auto hidden h-8 lg:flex" className="ml-auto hidden h-8 lg:flex"
> >
<Filter className="mr-2 size-4" /> <Filter className="mr-2 size-4" />
Filtro Filtro
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[150px]"> <DropdownMenuContent align="end" className="w-[150px]">
<DropdownMenuLabel>Filtra colonne</DropdownMenuLabel> <DropdownMenuLabel>Filtra colonne</DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{hidableColumns.map((column) => { {hidableColumns.map((column) => {
return ( return (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={column.id} key={column.id}
className="capitalize" className="capitalize"
checked={column.getIsVisible()} checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)} onCheckedChange={(value) => column.toggleVisibility(!!value)}
> >
{columns_titles[column.id]} {columns_titles[column.id]}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
); );
})} })}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); );
return null; return null;
} }

View file

@ -1,75 +1,75 @@
import type { Column } from "@tanstack/react-table"; import type { Column } from "@tanstack/react-table";
import { cn } from "~/lib/utils";
import { import {
DropdownMenu, ArrowDownIcon,
DropdownMenuContent, ArrowUpIcon,
DropdownMenuItem, ChevronsUpDown,
DropdownMenuSeparator, EyeOff,
DropdownMenuTrigger, } from "lucide-react";
} from "~/components/ui/dropdown-menu"; import type { HTMLAttributes } from "react";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
ArrowDownIcon, DropdownMenu,
ArrowUpIcon, DropdownMenuContent,
ChevronsUpDown, DropdownMenuItem,
EyeOff, DropdownMenuSeparator,
} from "lucide-react"; DropdownMenuTrigger,
import { type HTMLAttributes } from "react"; } from "~/components/ui/dropdown-menu";
import { cn } from "~/lib/utils";
interface DataTableColumnHeaderProps<TData, TValue> interface DataTableColumnHeaderProps<TData, TValue>
extends HTMLAttributes<HTMLDivElement> { extends HTMLAttributes<HTMLDivElement> {
column: Column<TData, TValue>; column: Column<TData, TValue>;
title: string; title: string;
} }
export function DataTableColumnHeader<TData, TValue>({ export function DataTableColumnHeader<TData, TValue>({
column, column,
title, title,
className, className,
}: DataTableColumnHeaderProps<TData, TValue>) { }: DataTableColumnHeaderProps<TData, TValue>) {
if (!column.getCanSort()) { if (!column.getCanSort()) {
return <div className={cn(className)}>{title}</div>; return <div className={cn(className)}>{title}</div>;
} }
return ( return (
<div className={cn("flex items-center space-x-2", className)}> <div className={cn("flex items-center space-x-2", className)}>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
className="data-[state=open]:bg-accent -ml-3 h-8" className="data-[state=open]:bg-accent -ml-3 h-8"
> >
<span>{title}</span> <span>{title}</span>
{column.getIsSorted() === "desc" ? ( {column.getIsSorted() === "desc" ? (
<ArrowDownIcon className="ml-2 size-4" /> <ArrowDownIcon className="ml-2 size-4" />
) : column.getIsSorted() === "asc" ? ( ) : column.getIsSorted() === "asc" ? (
<ArrowUpIcon className="ml-2 size-4" /> <ArrowUpIcon className="ml-2 size-4" />
) : ( ) : (
<ChevronsUpDown className="ml-2 size-4" /> <ChevronsUpDown className="ml-2 size-4" />
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="start"> <DropdownMenuContent align="start">
<DropdownMenuItem onClick={() => column.toggleSorting(false)}> <DropdownMenuItem onClick={() => column.toggleSorting(false)}>
<ArrowUpIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" /> <ArrowUpIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Asc Asc
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem onClick={() => column.toggleSorting(true)}> <DropdownMenuItem onClick={() => column.toggleSorting(true)}>
<ArrowDownIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" /> <ArrowDownIcon className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Desc Desc
</DropdownMenuItem> </DropdownMenuItem>
{column.getCanHide() && ( {column.getCanHide() && (
<> <>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={() => column.toggleVisibility(false)}> <DropdownMenuItem onClick={() => column.toggleVisibility(false)}>
<EyeOff className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" /> <EyeOff className="text-muted-foreground/70 mr-2 h-3.5 w-3.5" />
Nascondi Nascondi
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
); );
} }

View file

@ -1,103 +1,104 @@
import type { Table } from "@tanstack/react-table"; import type { Table } from "@tanstack/react-table";
import { import {
Select, ChevronFirst,
SelectContent, ChevronLast,
SelectItem, ChevronLeftIcon,
SelectTrigger, ChevronRightIcon,
SelectValue, } from "lucide-react";
} from "~/components/ui/select";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
ChevronFirst, Select,
ChevronLast, SelectContent,
ChevronLeftIcon, SelectItem,
ChevronRightIcon, SelectTrigger,
} from "lucide-react"; SelectValue,
} from "~/components/ui/select";
interface DataTablePaginationProps<TData> { interface DataTablePaginationProps<TData> {
table: Table<TData>; table: Table<TData>;
hasSelectRow?: boolean; hasSelectRow?: boolean;
} }
export function DataTablePagination<TData>({ export function DataTablePagination<TData>({
table, table,
hasSelectRow, hasSelectRow,
}: DataTablePaginationProps<TData>) { }: DataTablePaginationProps<TData>) {
return ( return (
<div className="flex items-center justify-between px-2"> <div className="flex items-center justify-between px-2">
<div className="text-muted-foreground flex-1 text-sm"> <div className="text-muted-foreground flex-1 text-sm">
{hasSelectRow && ( {hasSelectRow && (
<> <>
{table.getFilteredSelectedRowModel().rows.length} di{" "} {table.getFilteredSelectedRowModel().rows.length} di{" "}
{table.getFilteredRowModel().rows.length} righe selezionate. {table.getFilteredRowModel().rows.length} righe selezionate.
</> </>
)} )}
</div> </div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 lg:gap-x-8"> <div className="flex flex-wrap items-center gap-x-6 gap-y-2 lg:gap-x-8">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<p className="text-sm font-medium">Righe per pagina</p> <p className="text-sm font-medium">Righe per pagina</p>
<Select <Select
value={`${table.getState().pagination.pageSize}`} value={`${table.getState().pagination.pageSize}`}
onValueChange={(value) => { onValueChange={(value) => {
table.setPageSize(Number(value)); table.setPageSize(Number(value));
}} }}
> >
<SelectTrigger className="h-8 w-[70px]"> <SelectTrigger className="h-8 w-[70px]">
<SelectValue placeholder={table.getState().pagination.pageSize} /> <SelectValue placeholder={table.getState().pagination.pageSize} />
</SelectTrigger> </SelectTrigger>
<SelectContent side="top"> <SelectContent side="top">
{[10, 20, 30, 40, 50].map((pageSize) => ( {[10, 20, 30, 40, 50].map((pageSize) => (
<SelectItem key={pageSize} value={`${pageSize}`}> <SelectItem key={pageSize} value={`${pageSize}`}>
{pageSize} {pageSize}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<div className="flex items-center gap-x-6 lg:gap-x-8"> <div className="flex items-center gap-x-6 lg:gap-x-8">
<div className="flex items-center justify-between text-sm font-medium"> <div className="flex items-center justify-between text-sm font-medium">
Pagina {table.getState().pagination.pageIndex + 1} di{" "} Pagina {table.getState().pagination.pageIndex + 1} di{" "}
{table.getPageCount()} {table.getPageCount()}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Button <Button
variant="outline" variant="outline"
className="hidden size-8 p-0 lg:flex" className="hidden size-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)} onClick={() => table.setPageIndex(0)}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
<span className="sr-only">Vai alla prima pagina</span> <span className="sr-only">Vai alla prima pagina</span>
<ChevronFirst className="size-4" /> <ChevronFirst className="size-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="size-8 p-0" className="size-8 p-0"
onClick={() => table.previousPage()} onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()} disabled={!table.getCanPreviousPage()}
> >
<span className="sr-only">Vai alla pagina precedente</span> <span className="sr-only">Vai alla pagina precedente</span>
<ChevronLeftIcon className="size-4" /> <ChevronLeftIcon className="size-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="size-8 p-0" className="size-8 p-0"
onClick={() => table.nextPage()} onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
<span className="sr-only">Vai alla pagina successiva</span> <span className="sr-only">Vai alla pagina successiva</span>
<ChevronRightIcon className="size-4" /> <ChevronRightIcon className="size-4" />
</Button> </Button>
<Button <Button
variant="outline" variant="outline"
className="hidden size-8 p-0 lg:flex" className="hidden size-8 p-0 lg:flex"
onClick={() => table.setPageIndex(table.getPageCount() - 1)} onClick={() => table.setPageIndex(table.getPageCount() - 1)}
disabled={!table.getCanNextPage()} disabled={!table.getCanNextPage()}
> >
<span className="sr-only">Vai alla pagina finale</span> <span className="sr-only">Vai alla pagina finale</span>
<ChevronLast className="size-4" /> <ChevronLast className="size-4" />
</Button> </Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }

View file

@ -1,89 +1,88 @@
import type { Table } from "@tanstack/react-table"; import type { Table } from "@tanstack/react-table";
import Input from "~/components/custom_ui/input";
import { DataTableViewOptions } from "~/components/custom_ui/dataTable-ViewToggle";
import { Button } from "~/components/ui/button";
import { DataTableFacetedFilter } from "~/components/custom_ui/dataTable-FacetedFilter";
import { RotateCcw } from "lucide-react"; import { RotateCcw } from "lucide-react";
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { DataTableFacetedFilter } from "~/components/custom_ui/dataTable-FacetedFilter";
import { DataTableViewOptions } from "~/components/custom_ui/dataTable-ViewToggle";
import Input from "~/components/custom_ui/input";
import { Button } from "~/components/ui/button";
export type PinnedFiltro = { export type PinnedFiltro = {
title: string; title: string;
columnName: string; columnName: string;
options: { options: {
label: string; label: string;
value: string; value: string;
icon?: ComponentType<{ className?: string }>; icon?: ComponentType<{ className?: string }>;
}[]; }[];
}; };
export type SearchFiltro = { export type SearchFiltro = {
columnName: string; columnName: string;
placeholder: string; placeholder: string;
}; };
export function DataTableToolbar<TData>(props: { export function DataTableToolbar<TData>(props: {
table: Table<TData>; table: Table<TData>;
pinnedColumns?: PinnedFiltro[]; pinnedColumns?: PinnedFiltro[];
searchColumn?: SearchFiltro; searchColumn?: SearchFiltro;
columns_titles: Record<string, string>; columns_titles: Record<string, string>;
globalFilter: string; globalFilter: string;
}) { }) {
const { table, pinnedColumns, searchColumn, columns_titles, globalFilter } = const { table, pinnedColumns, searchColumn, columns_titles, globalFilter } =
props; props;
const isFiltered = table.getState().columnFilters.length > 0; const isFiltered = table.getState().columnFilters.length > 0;
return ( return (
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex flex-1 flex-wrap items-center gap-2"> <div className="flex flex-1 flex-wrap items-center gap-2">
{searchColumn && {searchColumn &&
(searchColumn.columnName ? ( (searchColumn.columnName ? (
<Input <Input
placeholder={searchColumn.placeholder} placeholder={searchColumn.placeholder}
value={ value={
(table (table
.getColumn(searchColumn.columnName) .getColumn(searchColumn.columnName)
?.getFilterValue() as string) ?? "" ?.getFilterValue() as string) ?? ""
} }
onChange={(event) => onChange={(event) =>
table table
.getColumn(searchColumn.columnName) .getColumn(searchColumn.columnName)
?.setFilterValue(event.target.value) ?.setFilterValue(event.target.value)
} }
className="mt-0 h-8 w-[150px]" className="mt-0 h-8 w-[150px]"
/> />
) : ( ) : (
<Input <Input
placeholder={searchColumn.placeholder} placeholder={searchColumn.placeholder}
value={globalFilter ?? ""} value={globalFilter ?? ""}
onChange={(event) => onChange={(event) =>
table.setGlobalFilter(String(event.target.value)) table.setGlobalFilter(String(event.target.value))
} }
className="mt-0 h-8 w-[150px]" className="mt-0 h-8 w-[150px]"
/> />
))} ))}
{pinnedColumns && {pinnedColumns?.map(
pinnedColumns.map( (column) =>
(column) => table.getColumn(column.columnName) && (
table.getColumn(column.columnName) && ( <DataTableFacetedFilter
<DataTableFacetedFilter key={column.columnName}
key={column.columnName} column={table.getColumn(column.columnName)}
column={table.getColumn(column.columnName)} title={column.title}
title={column.title} options={column.options}
options={column.options} />
/> ),
), )}
)}
{isFiltered && ( {isFiltered && (
<Button <Button
variant="ghost" variant="ghost"
onClick={() => table.resetColumnFilters()} onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3" className="h-8 px-2 lg:px-3"
> >
Reset Reset
<RotateCcw className="ml-2 size-4" /> <RotateCcw className="ml-2 size-4" />
</Button> </Button>
)} )}
</div> </div>
<DataTableViewOptions table={table} columns_titles={columns_titles} /> <DataTableViewOptions table={table} columns_titles={columns_titles} />
</div> </div>
); );
} }

View file

@ -2,16 +2,16 @@
// import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"; // import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { import {
type EmojiPickerListCategoryHeaderProps, type EmojiPickerListCategoryHeaderProps,
type EmojiPickerListEmojiProps, type EmojiPickerListEmojiProps,
type EmojiPickerListRowProps, type EmojiPickerListRowProps,
EmojiPicker as EmojiPickerPrimitive, EmojiPicker as EmojiPickerPrimitive,
// type SkinTone, // type SkinTone,
// useActiveEmoji, // useActiveEmoji,
// useSkinTone, // useSkinTone,
} from "frimousse"; } from "frimousse";
import { LoaderIcon, SearchIcon } from "lucide-react"; import { LoaderIcon, SearchIcon } from "lucide-react";
import * as React from "react"; import type * as React from "react";
// import { Button } from "~/components/ui/button"; // import { Button } from "~/components/ui/button";
// import { // import {
@ -24,115 +24,115 @@ import { cn } from "~/lib/utils";
// from https://ui-x.junwen-k.dev/docs/components/emoji-picker // from https://ui-x.junwen-k.dev/docs/components/emoji-picker
function EmojiPicker({ function EmojiPicker({
className, className,
...props ...props
}: React.ComponentProps<typeof EmojiPickerPrimitive.Root>) { }: React.ComponentProps<typeof EmojiPickerPrimitive.Root>) {
return ( return (
<EmojiPickerPrimitive.Root <EmojiPickerPrimitive.Root
data-slot="emoji-picker" data-slot="emoji-picker"
className={cn( className={cn(
"bg-popover text-popover-foreground isolate flex h-full w-fit flex-col overflow-hidden rounded-md", "bg-popover text-popover-foreground isolate flex h-full w-fit flex-col overflow-hidden rounded-md",
className, className,
)} )}
{...props} {...props}
/> />
); );
} }
function EmojiPickerSearch({ function EmojiPickerSearch({
className, className,
...props ...props
}: React.ComponentProps<typeof EmojiPickerPrimitive.Search>) { }: React.ComponentProps<typeof EmojiPickerPrimitive.Search>) {
return ( return (
<div <div
data-slot="emoji-picker-search-wrapper" data-slot="emoji-picker-search-wrapper"
className={cn("flex h-10 items-center gap-2 border-b px-3", className)} className={cn("flex h-10 items-center gap-2 border-b px-3", className)}
> >
<SearchIcon className="size-4 shrink-0 opacity-50" /> <SearchIcon className="size-4 shrink-0 opacity-50" />
<EmojiPickerPrimitive.Search <EmojiPickerPrimitive.Search
data-slot="emoji-picker-search" data-slot="emoji-picker-search"
className="placeholder:text-muted-foreground focus:border-accent-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50" className="placeholder:text-muted-foreground focus:border-accent-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden focus:ring-0 disabled:cursor-not-allowed disabled:opacity-50"
{...props} {...props}
/> />
</div> </div>
); );
} }
function EmojiPickerRow({ children, ...props }: EmojiPickerListRowProps) { function EmojiPickerRow({ children, ...props }: EmojiPickerListRowProps) {
return ( return (
<div data-slot="emoji-picker-row" className="scroll-my-1 px-1" {...props}> <div data-slot="emoji-picker-row" className="scroll-my-1 px-1" {...props}>
{children} {children}
</div> </div>
); );
} }
function EmojiPickerEmoji({ function EmojiPickerEmoji({
emoji, emoji,
className, className,
...props ...props
}: EmojiPickerListEmojiProps) { }: EmojiPickerListEmojiProps) {
return ( return (
<button <button
data-slot="emoji-picker-emoji" data-slot="emoji-picker-emoji"
className={cn( className={cn(
"data-[active]:bg-accent flex size-7 items-center justify-center rounded-xs text-base", "data-[active]:bg-accent flex size-7 items-center justify-center rounded-xs text-base",
className, className,
)} )}
{...props} {...props}
> >
{emoji.emoji} {emoji.emoji}
</button> </button>
); );
} }
function EmojiPickerCategoryHeader({ function EmojiPickerCategoryHeader({
category, category,
...props ...props
}: EmojiPickerListCategoryHeaderProps) { }: EmojiPickerListCategoryHeaderProps) {
return ( return (
<div <div
data-slot="emoji-picker-category-header" data-slot="emoji-picker-category-header"
className="bg-popover text-muted-foreground px-3 pt-3.5 pb-2 text-xs leading-none" className="bg-popover text-muted-foreground px-3 pt-3.5 pb-2 text-xs leading-none"
{...props} {...props}
> >
{category.label} {category.label}
</div> </div>
); );
} }
function EmojiPickerContent({ function EmojiPickerContent({
className, className,
...props ...props
}: React.ComponentProps<typeof EmojiPickerPrimitive.Viewport>) { }: React.ComponentProps<typeof EmojiPickerPrimitive.Viewport>) {
return ( return (
<EmojiPickerPrimitive.Viewport <EmojiPickerPrimitive.Viewport
data-slot="emoji-picker-viewport" data-slot="emoji-picker-viewport"
className={cn("relative flex-1 outline-hidden", className)} className={cn("relative flex-1 outline-hidden", className)}
{...props} {...props}
> >
<EmojiPickerPrimitive.Loading <EmojiPickerPrimitive.Loading
data-slot="emoji-picker-loading" data-slot="emoji-picker-loading"
className="text-muted-foreground absolute inset-0 flex items-center justify-center" className="text-muted-foreground absolute inset-0 flex items-center justify-center"
> >
<LoaderIcon className="size-4 animate-spin" /> <LoaderIcon className="size-4 animate-spin" />
</EmojiPickerPrimitive.Loading> </EmojiPickerPrimitive.Loading>
<EmojiPickerPrimitive.Empty <EmojiPickerPrimitive.Empty
data-slot="emoji-picker-empty" data-slot="emoji-picker-empty"
className="text-muted-foreground absolute inset-0 flex items-center justify-center text-sm" className="text-muted-foreground absolute inset-0 flex items-center justify-center text-sm"
> >
No emoji found. No emoji found.
</EmojiPickerPrimitive.Empty> </EmojiPickerPrimitive.Empty>
<EmojiPickerPrimitive.List <EmojiPickerPrimitive.List
data-slot="emoji-picker-list" data-slot="emoji-picker-list"
className="pb-1 select-none" className="pb-1 select-none"
components={{ components={{
Row: EmojiPickerRow, Row: EmojiPickerRow,
Emoji: EmojiPickerEmoji, Emoji: EmojiPickerEmoji,
CategoryHeader: EmojiPickerCategoryHeader, CategoryHeader: EmojiPickerCategoryHeader,
}} }}
/> />
</EmojiPickerPrimitive.Viewport> </EmojiPickerPrimitive.Viewport>
); );
} }
/*function EmojiPickerFooter({ /*function EmojiPickerFooter({
@ -228,8 +228,8 @@ function EmojiPickerSkinToneSelector() {
} }
*/ */
export { export {
EmojiPicker, EmojiPicker,
EmojiPickerSearch, EmojiPickerSearch,
EmojiPickerContent, EmojiPickerContent,
//EmojiPickerFooter, //EmojiPickerFooter,
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,202 +1,201 @@
import * as React from "react";
import type * as LabelPrimitive from "@radix-ui/react-label"; import type * as LabelPrimitive from "@radix-ui/react-label";
import { Slot } from "@radix-ui/react-slot"; import { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import { import {
Controller, Controller,
type ControllerProps, type ControllerProps,
type FieldPath, type FieldPath,
type FieldValues, type FieldValues,
FormProvider, FormProvider,
useFormContext, useFormContext,
} from "react-hook-form"; } from "react-hook-form";
import { cn } from "src/lib/utils";
import { Label } from "src/components/ui/label"; import { Label } from "src/components/ui/label";
import { cn } from "src/lib/utils";
import { env } from "~/env.mjs"; import { env } from "~/env.mjs";
const Form = FormProvider; const Form = FormProvider;
type FormFieldContextValue< type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = { > = {
name: TName; name: TName;
}; };
const FormFieldContext = React.createContext<FormFieldContextValue>( const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue, {} as FormFieldContextValue,
); );
const FormField = < const FormField = <
TFieldValues extends FieldValues = FieldValues, TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>, TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({ >({
...props ...props
}: ControllerProps<TFieldValues, TName>) => { }: ControllerProps<TFieldValues, TName>) => {
return ( return (
<FormFieldContext.Provider value={{ name: props.name }}> <FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} /> <Controller {...props} />
</FormFieldContext.Provider> </FormFieldContext.Provider>
); );
}; };
const useFormField = () => { const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext); const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext); const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext(); const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState); const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) { if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>"); throw new Error("useFormField should be used within <FormField>");
} }
const { id } = itemContext; const { id } = itemContext;
return { return {
id, id,
name: fieldContext.name, name: fieldContext.name,
formItemId: `${id}-form-item`, formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`, formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`, formMessageId: `${id}-form-item-message`,
...fieldState, ...fieldState,
}; };
}; };
type FormItemContextValue = { type FormItemContextValue = {
id: string; id: string;
}; };
const FormItemContext = React.createContext<FormItemContextValue>( const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue, {} as FormItemContextValue,
); );
const FormItem = React.forwardRef< const FormItem = React.forwardRef<
HTMLDivElement, HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const id = React.useId(); const id = React.useId();
return ( return (
<FormItemContext.Provider value={{ id }}> <FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} /> <div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider> </FormItemContext.Provider>
); );
}); });
FormItem.displayName = "FormItem"; FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef< const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>, React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField(); const { error, formItemId } = useFormField();
return ( return (
<Label <Label
ref={ref} ref={ref}
className={cn(error && "text-red-500 dark:text-red-600", className)} className={cn(error && "text-red-500 dark:text-red-600", className)}
htmlFor={formItemId} htmlFor={formItemId}
{...props} {...props}
/> />
); );
}); });
FormLabel.displayName = "FormLabel"; FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef< const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>, React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot> React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => { >(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } = const { error, formItemId, formDescriptionId, formMessageId } =
useFormField(); useFormField();
return ( return (
<Slot <Slot
ref={ref} ref={ref}
id={formItemId} id={formItemId}
aria-describedby={ aria-describedby={
!error !error
? `${formDescriptionId}` ? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}` : `${formDescriptionId} ${formMessageId}`
} }
aria-invalid={!!error} aria-invalid={!!error}
{...props} {...props}
/> />
); );
}); });
FormControl.displayName = "FormControl"; FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef< const FormDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => { >(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField(); const { formDescriptionId } = useFormField();
return ( return (
<p <p
ref={ref} ref={ref}
id={formDescriptionId} id={formDescriptionId}
className={cn( className={cn(
"text-sm text-neutral-500 dark:text-neutral-400", "text-sm text-neutral-500 dark:text-neutral-400",
className, className,
)} )}
{...props} {...props}
/> />
); );
}); });
FormDescription.displayName = "FormDescription"; FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef< const FormMessage = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField(); const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children; const body = error ? String(error?.message) : children;
if (!body) { if (!body) {
return null; return null;
} }
return ( return (
<p <p
ref={ref} ref={ref}
id={formMessageId} id={formMessageId}
className={cn( className={cn(
"text-sm font-medium text-red-500 dark:text-red-600", "text-sm font-medium text-red-500 dark:text-red-600",
className, className,
)} )}
{...props} {...props}
> >
{body} {body}
</p> </p>
); );
}); });
FormMessage.displayName = "FormMessage"; FormMessage.displayName = "FormMessage";
const FormDebug = React.forwardRef< const FormDebug = React.forwardRef<
React.ElementType, React.ElementType,
React.HTMLAttributes<HTMLDivElement> React.HTMLAttributes<HTMLDivElement>
>(async () => { >(async () => {
if (env.NODE_ENV !== "development") { if (env.NODE_ENV !== "development") {
return null; return null;
} }
const { control, formState } = useFormContext(); const { control, formState } = useFormContext();
const devtools = await import(/* webpackIgnore: true */ "@hookform/devtools"); const devtools = await import(/* webpackIgnore: true */ "@hookform/devtools");
if (Object.keys(formState.errors).length > 0) { if (Object.keys(formState.errors).length > 0) {
console.error("errors", formState.errors); console.error("errors", formState.errors);
} }
return <devtools.DevTool control={control} />; return <devtools.DevTool control={control} />;
}); });
FormDebug.displayName = "FormDebug"; FormDebug.displayName = "FormDebug";
export { export {
useFormField, useFormField,
Form, Form,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl, FormControl,
FormDescription, FormDescription,
FormMessage, FormMessage,
FormField, FormField,
FormDebug, FormDebug,
}; };

View file

@ -2,6 +2,6 @@ import dynamic from "next/dynamic";
import type React from "react"; import type React from "react";
export const DevT: React.ElementType = dynamic( export const DevT: React.ElementType = dynamic(
() => import("@hookform/devtools").then((mod) => mod.DevTool), () => import("@hookform/devtools").then((mod) => mod.DevTool),
{ ssr: false }, { ssr: false },
); );

View file

@ -1,35 +1,35 @@
import { import {
forwardRef, type ComponentPropsWithoutRef,
type ComponentPropsWithoutRef, forwardRef,
type ReactNode, type ReactNode,
} from "react"; } from "react";
import Input from "~/components/custom_ui/input"; import Input from "~/components/custom_ui/input";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
type InputWIconProps = ComponentPropsWithoutRef<typeof Input> & { type InputWIconProps = ComponentPropsWithoutRef<typeof Input> & {
iconPosition: "start" | "end"; iconPosition: "start" | "end";
children: ReactNode; children: ReactNode;
}; };
const InputWIcon = forwardRef<HTMLInputElement, InputWIconProps>( const InputWIcon = forwardRef<HTMLInputElement, InputWIconProps>(
({ children, iconPosition, ...props }, ref) => { ({ children, iconPosition, ...props }, ref) => {
return ( return (
<div className="relative"> <div className="relative">
<Input <Input
className={cn("peer", iconPosition == "start" ? "ps-9" : "pe-9")} className={cn("peer", iconPosition === "start" ? "ps-9" : "pe-9")}
{...props} {...props}
ref={ref} ref={ref}
/> />
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-y-0 flex items-center justify-center text-muted-foreground/80 peer-disabled:opacity-50", "pointer-events-none absolute inset-y-0 flex items-center justify-center text-muted-foreground/80 peer-disabled:opacity-50",
iconPosition == "start" ? "start-0 ps-3" : "end-0 pe-3", iconPosition === "start" ? "start-0 ps-3" : "end-0 pe-3",
)} )}
> >
{children} {children}
</div> </div>
</div> </div>
); );
}, },
); );
InputWIcon.displayName = "InputWIcon"; InputWIcon.displayName = "InputWIcon";

View file

@ -4,19 +4,19 @@ import { cn } from "src/lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>; type InputProps = InputHTMLAttributes<HTMLInputElement>;
const Input = forwardRef<HTMLInputElement, InputProps>( const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
type={type} type={type}
ref={ref} ref={ref}
{...props} {...props}
className={cn( className={cn(
`border-border bg-background text-primary placeholder:text-muted-foreground hover:border-muted-foreground focus:border-foreground focus:ring-foreground w-full rounded-lg border px-3 py-2 font-medium shadow-xs outline-hidden transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50`, `border-border bg-background text-primary placeholder:text-muted-foreground hover:border-muted-foreground focus:border-foreground focus:ring-foreground w-full rounded-lg border px-3 py-2 font-medium shadow-xs outline-hidden transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:cursor-not-allowed disabled:opacity-50`,
className, className,
)} )}
/> />
); );
}, },
); );
Input.displayName = "Input"; Input.displayName = "Input";

View file

@ -1,22 +1,23 @@
import { type ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
const DualInputLayout = (props: { const DualInputLayout = (props: {
children1: ReactNode; children1: ReactNode;
children2: ReactNode; children2: ReactNode;
className?: string; className?: string;
}) => { }) => {
const { children1, children2, className } = props; const { children1, children2, className } = props;
return ( return (
<div <div
className={cn( className={cn(
"flex flex-col items-center gap-x-6 gap-y-5 sm:flex-row [&>*]:w-full", "flex flex-col items-center gap-x-6 gap-y-5 sm:flex-row [&>*]:w-full",
className, className,
)} )}
> >
<div>{children1}</div> <div>{children1}</div>
<div>{children2}</div> <div>{children2}</div>
</div> </div>
); );
}; };
export { DualInputLayout }; export { DualInputLayout };

View file

@ -1,34 +1,34 @@
import {
Button,
buttonVariants,
type ButtonProps,
} from "~/components/ui/button";
import { LoaderCircle } from "lucide-react"; import { LoaderCircle } from "lucide-react";
import {
Button,
type ButtonProps,
buttonVariants,
} from "~/components/ui/button";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
const LoadingButton = ({ const LoadingButton = ({
className, className,
variant, variant,
size, size,
loading, loading,
...props ...props
}: ButtonProps & { loading?: boolean }) => { }: ButtonProps & { loading?: boolean }) => {
return ( return (
<Button <Button
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
{...props} {...props}
> >
{loading && ( {loading && (
<LoaderCircle <LoaderCircle
className="-ms-1 me-2 animate-spin" className="-ms-1 me-2 animate-spin"
size={16} size={16}
strokeWidth={2} strokeWidth={2}
aria-hidden="true" aria-hidden="true"
/> />
)} )}
{props.children} {props.children}
</Button> </Button>
); );
}; };
export default LoadingButton; export default LoadingButton;

View file

@ -1,347 +1,352 @@
"use client"; "use client";
import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react"; import { CheckIcon, ChevronsUpDownIcon, XIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { import {
Command, type ComponentPropsWithoutRef,
CommandEmpty, createContext,
CommandGroup, type ReactNode,
CommandInput, useCallback,
CommandItem, useContext,
CommandList, useEffect,
CommandSeparator, useLayoutEffect,
} from "~/components/ui/command"; useRef,
import { useState,
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react"; } from "react";
import { Badge } from "~/components/ui/badge"; import { Badge } from "~/components/ui/badge";
import { Button } from "~/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "~/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { cn } from "~/lib/utils";
type MultiSelectContextType = { type MultiSelectContextType = {
open: boolean; open: boolean;
setOpen: (open: boolean) => void; setOpen: (open: boolean) => void;
selectedValues: Set<string>; selectedValues: Set<string>;
toggleValue: (value: string) => void; toggleValue: (value: string) => void;
items: Map<string, ReactNode>; items: Map<string, ReactNode>;
onItemAdded: (value: string, label: ReactNode) => void; onItemAdded: (value: string, label: ReactNode) => void;
}; };
const MultiSelectContext = createContext<MultiSelectContextType | null>(null); const MultiSelectContext = createContext<MultiSelectContextType | null>(null);
export function MultiSelect({ export function MultiSelect({
children, children,
values, values,
defaultValues, defaultValues,
onValuesChange, onValuesChange,
}: { }: {
children: ReactNode; children: ReactNode;
values?: string[]; values?: string[];
defaultValues?: string[]; defaultValues?: string[];
onValuesChange?: (values: string[]) => void; onValuesChange?: (values: string[]) => void;
}) { }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedValues, setSelectedValues] = useState( const [selectedValues, setSelectedValues] = useState(
new Set<string>(values ?? defaultValues), new Set<string>(values ?? defaultValues),
); );
const [items, setItems] = useState<Map<string, ReactNode>>(new Map()); const [items, setItems] = useState<Map<string, ReactNode>>(new Map());
function toggleValue(value: string) { function toggleValue(value: string) {
const getNewSet = (prev: Set<string>) => { const getNewSet = (prev: Set<string>) => {
const newSet = new Set(prev); const newSet = new Set(prev);
if (newSet.has(value)) { if (newSet.has(value)) {
newSet.delete(value); newSet.delete(value);
} else { } else {
newSet.add(value); newSet.add(value);
} }
return newSet; return newSet;
}; };
setSelectedValues(getNewSet); setSelectedValues(getNewSet);
onValuesChange?.([...getNewSet(selectedValues)]); onValuesChange?.([...getNewSet(selectedValues)]);
} }
const onItemAdded = useCallback((value: string, label: ReactNode) => { const onItemAdded = useCallback((value: string, label: ReactNode) => {
setItems((prev) => { setItems((prev) => {
if (prev.get(value) === label) return prev; if (prev.get(value) === label) return prev;
return new Map(prev).set(value, label); return new Map(prev).set(value, label);
}); });
}, []); }, []);
return ( return (
<MultiSelectContext <MultiSelectContext
value={{ value={{
open, open,
setOpen, setOpen,
selectedValues: values ? new Set(values) : selectedValues, selectedValues: values ? new Set(values) : selectedValues,
toggleValue, toggleValue,
items, items,
onItemAdded, onItemAdded,
}} }}
> >
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
{children} {children}
</Popover> </Popover>
</MultiSelectContext> </MultiSelectContext>
); );
} }
export function MultiSelectTrigger({ export function MultiSelectTrigger({
className, className,
children, children,
...props ...props
}: { }: {
className?: string; className?: string;
children?: ReactNode; children?: ReactNode;
} & ComponentPropsWithoutRef<typeof Button>) { } & ComponentPropsWithoutRef<typeof Button>) {
const { open } = useMultiSelectContext(); const { open } = useMultiSelectContext();
return ( return (
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
{...props} {...props}
variant={props.variant ?? "outline"} variant={props.variant ?? "outline"}
role={props.role ?? "combobox"} role={props.role ?? "combobox"}
aria-expanded={props["aria-expanded"] ?? open} aria-expanded={props["aria-expanded"] ?? open}
className={cn( className={cn(
"aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 border-input data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex h-auto min-h-9 w-fit items-center justify-between gap-2 overflow-hidden rounded-md border bg-transparent px-3 py-1.5 text-sm whitespace-nowrap shadow-xs outline-hidden transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 border-input data-[placeholder]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 dark:bg-input/30 dark:hover:bg-input/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex h-auto min-h-9 w-fit items-center justify-between gap-2 overflow-hidden rounded-md border bg-transparent px-3 py-1.5 text-sm whitespace-nowrap shadow-xs outline-hidden transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className, className,
)} )}
> >
{children} {children}
<ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" /> <ChevronsUpDownIcon className="size-4 shrink-0 opacity-50" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
); );
} }
export function MultiSelectValue({ export function MultiSelectValue({
placeholder, placeholder,
clickToRemove = true, clickToRemove = true,
className, className,
overflowBehavior = "wrap-when-open", overflowBehavior = "wrap-when-open",
...props ...props
}: { }: {
placeholder?: string; placeholder?: string;
clickToRemove?: boolean; clickToRemove?: boolean;
overflowBehavior?: "wrap" | "wrap-when-open" | "cutoff"; overflowBehavior?: "wrap" | "wrap-when-open" | "cutoff";
} & Omit<ComponentPropsWithoutRef<"div">, "children">) { } & Omit<ComponentPropsWithoutRef<"div">, "children">) {
const { selectedValues, toggleValue, items, open } = useMultiSelectContext(); const { selectedValues, toggleValue, items, open } = useMultiSelectContext();
const [overflowAmount, setOverflowAmount] = useState(0); const [overflowAmount, setOverflowAmount] = useState(0);
const valueRef = useRef<HTMLDivElement>(null); const valueRef = useRef<HTMLDivElement>(null);
const overflowRef = useRef<HTMLDivElement>(null); const overflowRef = useRef<HTMLDivElement>(null);
const itemsRef = useRef<Set<HTMLElement>>(new Set()); const itemsRef = useRef<Set<HTMLElement>>(new Set());
const shouldWrap = const shouldWrap =
overflowBehavior === "wrap" || overflowBehavior === "wrap" ||
(overflowBehavior === "wrap-when-open" && open); (overflowBehavior === "wrap-when-open" && open);
useEffect(() => { useEffect(() => {
if (!shouldWrap) return; if (!shouldWrap) return;
itemsRef.current.forEach((child) => child.style.removeProperty("display")); itemsRef.current.forEach((child) => child.style.removeProperty("display"));
}, [shouldWrap]); }, [shouldWrap]);
const checkOverflow = useCallback(() => { const checkOverflow = useCallback(() => {
if (valueRef.current == null) return; if (valueRef.current == null) return;
const containerElement = valueRef.current; const containerElement = valueRef.current;
const overflowElement = overflowRef.current; const overflowElement = overflowRef.current;
if (overflowElement != null) overflowElement.style.display = "none"; if (overflowElement != null) overflowElement.style.display = "none";
itemsRef.current.forEach((child) => child.style.removeProperty("display")); itemsRef.current.forEach((child) => child.style.removeProperty("display"));
let amount = 0; let amount = 0;
for (let i = itemsRef.current.size - 1; i >= 0; i--) { for (let i = itemsRef.current.size - 1; i >= 0; i--) {
const child = [...itemsRef.current][i]; const child = [...itemsRef.current][i];
if ( if (
containerElement.scrollWidth <= containerElement.clientWidth || containerElement.scrollWidth <= containerElement.clientWidth ||
!child !child
) { ) {
break; break;
} }
amount = itemsRef.current.size - i; amount = itemsRef.current.size - i;
child.style.display = "none"; child.style.display = "none";
overflowElement?.style.removeProperty("display"); overflowElement?.style.removeProperty("display");
} }
setOverflowAmount(amount); setOverflowAmount(amount);
}, []); }, []);
useEffect(() => { useEffect(() => {
if (valueRef.current == null) return; if (valueRef.current == null) return;
const observer = new ResizeObserver(checkOverflow); const observer = new ResizeObserver(checkOverflow);
observer.observe(valueRef.current); observer.observe(valueRef.current);
return () => observer.disconnect(); return () => observer.disconnect();
}, [checkOverflow]); }, [checkOverflow]);
useLayoutEffect(() => { useLayoutEffect(() => {
checkOverflow(); checkOverflow();
}, [selectedValues, checkOverflow]); }, [selectedValues, checkOverflow]);
if (selectedValues.size === 0 && placeholder) { if (selectedValues.size === 0 && placeholder) {
return ( return (
<span className="text-muted-foreground font-normal">{placeholder}</span> <span className="text-muted-foreground font-normal">{placeholder}</span>
); );
} }
return ( return (
<div <div
{...props} {...props}
ref={valueRef} ref={valueRef}
className={cn( className={cn(
"flex w-full gap-1.5 overflow-hidden", "flex w-full gap-1.5 overflow-hidden",
shouldWrap && "h-full flex-wrap", shouldWrap && "h-full flex-wrap",
className, className,
)} )}
> >
{[...selectedValues] {[...selectedValues]
.filter((value) => items.has(value)) .filter((value) => items.has(value))
.map((value) => ( .map((value) => (
<Badge <Badge
ref={(el) => { ref={(el) => {
if (el == null) return; if (el == null) return;
itemsRef.current.add(el); itemsRef.current.add(el);
return () => { return () => {
itemsRef.current.delete(el); itemsRef.current.delete(el);
}; };
}} }}
variant="outline" variant="outline"
className="group flex items-center gap-1" className="group flex items-center gap-1"
key={value} key={value}
onClick={ onClick={
clickToRemove clickToRemove
? (e) => { ? (e) => {
e.stopPropagation(); e.stopPropagation();
toggleValue(value); toggleValue(value);
} }
: undefined : undefined
} }
> >
{items.get(value)} {items.get(value)}
{clickToRemove && ( {clickToRemove && (
<XIcon className="text-muted-foreground group-hover:text-destructive size-2" /> <XIcon className="text-muted-foreground group-hover:text-destructive size-2" />
)} )}
</Badge> </Badge>
))} ))}
<Badge <Badge
style={{ style={{
display: overflowAmount > 0 && !shouldWrap ? "block" : "none", display: overflowAmount > 0 && !shouldWrap ? "block" : "none",
}} }}
variant="outline" variant="outline"
ref={overflowRef} ref={overflowRef}
> >
+{overflowAmount} +{overflowAmount}
</Badge> </Badge>
</div> </div>
); );
} }
export function MultiSelectContent({ export function MultiSelectContent({
search = true, search = true,
children, children,
...props ...props
}: { }: {
search?: boolean | { placeholder?: string; emptyMessage?: string }; search?: boolean | { placeholder?: string; emptyMessage?: string };
children: ReactNode; children: ReactNode;
} & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) { } & Omit<ComponentPropsWithoutRef<typeof Command>, "children">) {
const canSearch = typeof search === "object" ? true : search; const canSearch = typeof search === "object" ? true : search;
return ( return (
<> <>
<div style={{ display: "none" }}> <div style={{ display: "none" }}>
<Command> <Command>
<CommandList>{children}</CommandList> <CommandList>{children}</CommandList>
</Command> </Command>
</div> </div>
<PopoverContent className="min-w-[var(--radix-popover-trigger-width)] p-0"> <PopoverContent className="min-w-[var(--radix-popover-trigger-width)] p-0">
<Command {...props}> <Command {...props}>
{canSearch ? ( {canSearch ? (
<CommandInput <CommandInput
placeholder={ placeholder={
typeof search === "object" ? search.placeholder : undefined typeof search === "object" ? search.placeholder : undefined
} }
/> />
) : ( ) : (
// eslint-disable-next-line jsx-a11y/no-autofocus // biome-ignore lint/a11y/noAriaHiddenOnFocusable: <intended>
<button autoFocus aria-hidden="true" className="sr-only" /> <button
)} autoFocus
<CommandList> aria-hidden="true"
{canSearch && ( className="sr-only"
<CommandEmpty> type="button"
{typeof search === "object" ? search.emptyMessage : undefined} />
</CommandEmpty> )}
)} <CommandList>
{children} {canSearch && (
</CommandList> <CommandEmpty>
</Command> {typeof search === "object" ? search.emptyMessage : undefined}
</PopoverContent> </CommandEmpty>
</> )}
); {children}
</CommandList>
</Command>
</PopoverContent>
</>
);
} }
export function MultiSelectItem({ export function MultiSelectItem({
value, value,
children, children,
badgeLabel, badgeLabel,
onSelect, onSelect,
...props ...props
}: { }: {
badgeLabel?: ReactNode; badgeLabel?: ReactNode;
value: string; value: string;
} & Omit<ComponentPropsWithoutRef<typeof CommandItem>, "value">) { } & Omit<ComponentPropsWithoutRef<typeof CommandItem>, "value">) {
const { toggleValue, selectedValues, onItemAdded } = useMultiSelectContext(); const { toggleValue, selectedValues, onItemAdded } = useMultiSelectContext();
const isSelected = selectedValues.has(value); const isSelected = selectedValues.has(value);
useEffect(() => { useEffect(() => {
onItemAdded(value, badgeLabel ?? children); onItemAdded(value, badgeLabel ?? children);
}, [value, children, onItemAdded, badgeLabel]); }, [value, children, onItemAdded, badgeLabel]);
return ( return (
<CommandItem <CommandItem
{...props} {...props}
value={value} value={value}
onSelect={(v) => { onSelect={(v) => {
toggleValue(v); toggleValue(v);
onSelect?.(v); onSelect?.(v);
}} }}
> >
<CheckIcon <CheckIcon
className={cn("mr-2 size-4", isSelected ? "opacity-100" : "opacity-0")} className={cn("mr-2 size-4", isSelected ? "opacity-100" : "opacity-0")}
/> />
{children} {children}
</CommandItem> </CommandItem>
); );
} }
export function MultiSelectGroup( export function MultiSelectGroup(
props: ComponentPropsWithoutRef<typeof CommandGroup>, props: ComponentPropsWithoutRef<typeof CommandGroup>,
) { ) {
return <CommandGroup {...props} />; return <CommandGroup {...props} />;
} }
export function MultiSelectSeparator( export function MultiSelectSeparator(
props: ComponentPropsWithoutRef<typeof CommandSeparator>, props: ComponentPropsWithoutRef<typeof CommandSeparator>,
) { ) {
return <CommandSeparator {...props} />; return <CommandSeparator {...props} />;
} }
function useMultiSelectContext() { function useMultiSelectContext() {
const context = useContext(MultiSelectContext); const context = useContext(MultiSelectContext);
if (context == null) { if (context == null) {
throw new Error( throw new Error(
"useMultiSelectContext must be used within a MultiSelectContext", "useMultiSelectContext must be used within a MultiSelectContext",
); );
} }
return context; return context;
} }

View file

@ -1,98 +1,97 @@
/* eslint-disable better-tailwindcss/no-unregistered-classes */
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useState, type JSX } from "react"; import { type JSX, useRef, useState } from "react";
import ReactSelect, { import ReactSelect, {
type MenuListProps, type CSSObjectWithLabel,
type GroupBase, type GroupBase,
type CSSObjectWithLabel, type MenuListProps,
} from "react-select"; } from "react-select";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
export type ListOption = { export type ListOption = {
label: string; label: string;
value: string; value: string;
}; };
type BaseProps = { type BaseProps = {
elementId: string; elementId: string;
elementName: string; elementName: string;
options: ListOption[]; options: ListOption[];
placeholder: string; placeholder: string;
disabled?: boolean; disabled?: boolean;
menuPlacement?: "auto" | "bottom" | "top"; menuPlacement?: "auto" | "bottom" | "top";
defaultValue?: ListOption | ListOption[]; defaultValue?: ListOption | ListOption[];
}; };
type MultiProps = { type MultiProps = {
isMulti: true; isMulti: true;
onValueChange: (value: ListOption[]) => void; onValueChange: (value: ListOption[]) => void;
}; };
type SigletProps = { type SigletProps = {
isMulti: false; isMulti: false;
onValueChange: (value: ListOption) => void; onValueChange: (value: ListOption) => void;
}; };
type MultiSelectProps = BaseProps & (MultiProps | SigletProps); type MultiSelectProps = BaseProps & (MultiProps | SigletProps);
const VirtualizedMenuList = ( const VirtualizedMenuList = (
props: MenuListProps< props: MenuListProps<
ListOption, ListOption,
MultiSelectProps["isMulti"], MultiSelectProps["isMulti"],
GroupBase<ListOption> GroupBase<ListOption>
>, >,
) => { ) => {
const rows = props.children as JSX.Element[]; const rows = props.children as JSX.Element[];
const parentRef = useRef<HTMLDivElement>(null); const parentRef = useRef<HTMLDivElement>(null);
const count = rows.length; const count = rows.length;
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count, count,
getScrollElement: () => parentRef.current, getScrollElement: () => parentRef.current,
estimateSize: () => 45, estimateSize: () => 45,
}); });
const items = virtualizer.getVirtualItems(); const items = virtualizer.getVirtualItems();
return ( return (
<div <div
ref={parentRef} ref={parentRef}
className="List" className="List"
style={{ style={{
height: `${virtualizer.getTotalSize()}px`, height: `${virtualizer.getTotalSize()}px`,
maxHeight: "300px", maxHeight: "300px",
width: "auto", width: "auto",
overflowY: "auto", overflowY: "auto",
contain: "strict", contain: "strict",
}} }}
> >
<div <div
style={{ style={{
height: virtualizer.getTotalSize(), height: virtualizer.getTotalSize(),
width: "100%", width: "100%",
position: "relative", position: "relative",
}} }}
> >
<div <div
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: "100%",
transform: `translateY(${items[0]?.start ?? 0}px)`, transform: `translateY(${items[0]?.start ?? 0}px)`,
}} }}
> >
{items.map((virtualRow) => ( {items.map((virtualRow) => (
<div <div
key={virtualRow.key} key={virtualRow.key}
data-index={virtualRow.index} data-index={virtualRow.index}
ref={virtualizer.measureElement} ref={virtualizer.measureElement}
className={virtualRow.index % 2 ? "ListItemOdd" : "ListItemEven"} className={virtualRow.index % 2 ? "ListItemOdd" : "ListItemEven"}
> >
{rows[virtualRow.index]} {rows[virtualRow.index]}
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
); );
}; };
/** /**
@ -116,178 +115,180 @@ const VirtualizedMenuList = (
* /> * />
*/ */
export const MultiSelect = ({ export const MultiSelect = ({
elementId, elementId,
defaultValue, defaultValue,
elementName, elementName,
isMulti, isMulti,
onValueChange, onValueChange,
options, options,
placeholder, placeholder,
disabled, disabled,
menuPlacement, menuPlacement,
}: MultiSelectProps) => { }: MultiSelectProps) => {
const [menuOpen, setMenuOpen] = useState(false); const [menuOpen, setMenuOpen] = useState(false);
return ( return (
<ReactSelect <ReactSelect
menuPlacement={menuPlacement} menuPlacement={menuPlacement}
components={{ MenuList: VirtualizedMenuList }} components={{ MenuList: VirtualizedMenuList }}
inputId={elementId} inputId={elementId}
isMulti={isMulti} isMulti={isMulti}
isDisabled={disabled} isDisabled={disabled}
instanceId={elementId} instanceId={elementId}
name={elementName} name={elementName}
options={options} options={options}
onChange={(values) => { onChange={(values) => {
if (isMulti) { if (isMulti) {
const valore = values as ListOption[]; const valore = values as ListOption[];
onValueChange(valore); onValueChange(valore);
} else { } else {
const valore = values as ListOption; const valore = values as ListOption;
onValueChange(valore); onValueChange(valore);
} }
}} }}
defaultValue={defaultValue} defaultValue={defaultValue}
placeholder={placeholder} placeholder={placeholder}
onMenuOpen={() => setMenuOpen(true)} onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => setMenuOpen(false)} onMenuClose={() => setMenuOpen(false)}
styles={{ styles={{
container: (base, state) => container: (base, state) =>
({ ({
...base, ...base,
":hover .select__indicator-separator": { ":hover .select__indicator-separator": {
backgroundColor: state.isFocused backgroundColor: state.isFocused
? "rgb(161,161,170)" ? "rgb(161,161,170)"
: "rgb(161,161,170)", : "rgb(161,161,170)",
}, },
":hover .select__dropdown-indicator": { ":hover .select__dropdown-indicator": {
color: state.isFocused ? "rgb(161,161,170)" : "rgb(161,161,170)", color: state.isFocused ? "rgb(161,161,170)" : "rgb(161,161,170)",
}, },
}) as CSSObjectWithLabel, }) as CSSObjectWithLabel,
control: (base, state) => control: (base, state) =>
({ ({
...base, ...base,
minHeight: "42px", minHeight: "42px",
borderRadius: "0.5rem", borderRadius: "0.5rem",
boxShadow: undefined, boxShadow: undefined,
border: state.isFocused border: state.isFocused
? "1px solid rgb(161,161,170)" ? "1px solid rgb(161,161,170)"
: "1px solid rgb(212 ,212 ,216)", : "1px solid rgb(212 ,212 ,216)",
"&:hover": { "&:hover": {
borderColor: state.isFocused borderColor: state.isFocused
? "rgb(161,161,170)" ? "rgb(161,161,170)"
: "rgb(161 ,161 ,170)", : "rgb(161 ,161 ,170)",
}, },
"& .select__indicator-separator": { "& .select__indicator-separator": {
backgroundColor: state.isFocused backgroundColor: state.isFocused
? "rgb(161,161,170)" ? "rgb(161,161,170)"
: "rgb(212 ,212 ,216)", : "rgb(212 ,212 ,216)",
}, },
"& .select__dropdown-indicator": { "& .select__dropdown-indicator": {
color: state.isFocused color: state.isFocused
? "rgb(161,161,170)" ? "rgb(161,161,170)"
: "rgb(212 ,212 ,216)", : "rgb(212 ,212 ,216)",
transition: "none", transition: "none",
}, },
}) as CSSObjectWithLabel, }) as CSSObjectWithLabel,
multiValue: (base) => multiValue: (base) =>
({ ({
...base, ...base,
backgroundColor: undefined, backgroundColor: undefined,
borderRadius: "0.5rem", borderRadius: "0.5rem",
}) as CSSObjectWithLabel, }) as CSSObjectWithLabel,
multiValueLabel: (base) => multiValueLabel: (base) =>
({ ({
...base, ...base,
borderRadius: "0.5rem", borderRadius: "0.5rem",
}) as CSSObjectWithLabel, }) as CSSObjectWithLabel,
menu: (base) => menu: (base) =>
({ ({
...base, ...base,
borderRadius: "0.5rem", borderRadius: "0.5rem",
}) as CSSObjectWithLabel, }) as CSSObjectWithLabel,
option: (base, state) => option: (base, state) =>
({ ({
...base, ...base,
backgroundColor: undefined, backgroundColor: undefined,
color: state.isSelected ? undefined : undefined, color: state.isSelected ? undefined : undefined,
"&:hover": { "&:hover": {
backgroundColor: undefined, backgroundColor: undefined,
color: state.isSelected ? undefined : undefined, color: state.isSelected ? undefined : undefined,
}, },
"&:active": { "&:active": {
backgroundColor: "rgb(161, 161, 170)", backgroundColor: "rgb(161, 161, 170)",
color: "rgb(255, 255, 255)", color: "rgb(255, 255, 255)",
}, },
"&:focus": { "&:focus": {
backgroundColor: undefined, backgroundColor: undefined,
}, },
}) as CSSObjectWithLabel, }) as CSSObjectWithLabel,
}} }}
classNames={{ classNames={{
valueContainer: () => cn("min-h-[38px]"), valueContainer: () => cn("min-h-[38px]"),
control: () => control: () =>
cn( cn(
"mt-2 w-full rounded-lg h-min-[42px] dark:bg-primary bg-white shadow-xs outline-hidden text-foreground dark:text-white", "mt-2 w-full rounded-lg h-min-[42px] dark:bg-primary bg-white shadow-xs outline-hidden text-foreground dark:text-white",
), ),
option: () => option: () =>
cn( cn(
"dark:bg-black text-foreground bg-white dark:text-white border-b border-neutral-300 dark:border-foreground shadow-xs outline-hidden hover:text-primary hover:bg-neutral-200 dark:hover:bg-neutral-700", "dark:bg-black text-foreground bg-white dark:text-white border-b border-neutral-300 dark:border-foreground shadow-xs outline-hidden hover:text-primary hover:bg-neutral-200 dark:hover:bg-neutral-700",
), ),
menu: () => menu: () =>
cn( cn(
`dark:bg-black text-foreground bg-white dark:text-white border shadow-xs outline-hidden `dark:bg-black text-foreground bg-white dark:text-white border shadow-xs outline-hidden
dark:border-foreground border-neutral-300 overflow-hidden`, dark:border-foreground border-neutral-300 overflow-hidden`,
menuOpen menuOpen
? "animate-in fade-in-0 zoom-in-90" ? "animate-in fade-in-0 zoom-in-90"
: "animate-out fade-out-0 zoom-out-90", : "animate-out fade-out-0 zoom-out-90",
), ),
multiValueLabel: () => multiValueLabel: () =>
cn( cn(
"dark:bg-black rounded-lg text-primary bg-white dark:text-white outline-hidden", "dark:bg-black rounded-lg text-primary bg-white dark:text-white outline-hidden",
), ),
multiValue: () => multiValue: () =>
cn( cn(
"overflow-hidden dark:bg-black rounded-lg text-primary bg-white dark:text-white border border-neutral-300 dark:border-foreground shadow-xs outline-hidden", "overflow-hidden dark:bg-black rounded-lg text-primary bg-white dark:text-white border border-neutral-300 dark:border-foreground shadow-xs outline-hidden",
), ),
multiValueRemove: () => cn("rounded-s-none rounded-e-md"), multiValueRemove: () => cn("rounded-s-none rounded-e-md"),
input: () => cn("*:ring-0 focus:*:ring-0"), input: () => cn("*:ring-0 focus:*:ring-0"),
singleValue: () => cn("text-primary font-medium"), singleValue: () => cn("text-primary font-medium"),
}} }}
classNamePrefix="select" classNamePrefix="select"
/> />
); );
}; };
type UnpackOptions = { type UnpackOptions = {
options: string[]; options: string[];
}; };
export const UnpackOptions = ({ options }: UnpackOptions) => { export const UnpackOptions = ({ options }: UnpackOptions) => {
return options.map((v, idx) => ({ value: idx.toString(), label: v })); return options.map((v, idx) => ({ value: idx.toString(), label: v }));
}; };
type RepackValues = { type RepackValues = {
values: string | string[]; values: string | string[];
options: string[]; options: string[];
}; };
export const RepackValues = ({ values, options }: RepackValues) => { export const RepackValues = ({ values, options }: RepackValues) => {
if (Array.isArray(values)) { if (Array.isArray(values)) {
return values.map((v) => ({ return values.map((v) => ({
label: options[Number.parseInt(v)]!, // biome-ignore lint/style/noNonNullAssertion: <intended>
value: v, label: options[Number.parseInt(v)]!,
})); value: v,
} }));
return { }
label: options[Number.parseInt(values)]!, return {
value: values, // biome-ignore lint/style/noNonNullAssertion: <intended>
}; label: options[Number.parseInt(values)]!,
value: values,
};
}; };

View file

@ -3,40 +3,40 @@
import * as DialogPrimitive from "@radix-ui/react-dialog"; import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { import {
createContext, type ComponentPropsWithoutRef,
forwardRef, createContext,
useContext, type Dispatch,
useEffect, type ElementRef,
useRef, forwardRef,
useState, type HTMLAttributes,
type ComponentPropsWithoutRef, type PointerEvent,
type Dispatch, type ReactNode,
type ElementRef, type SetStateAction,
type HTMLAttributes, useContext,
type PointerEvent, useEffect,
type ReactNode, useRef,
type SetStateAction, useState,
} from "react"; } from "react";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
interface DialogContextValue { interface DialogContextValue {
innerOpen: boolean; innerOpen: boolean;
setInnerOpen: Dispatch<SetStateAction<boolean>>; setInnerOpen: Dispatch<SetStateAction<boolean>>;
} }
const DialogContext = createContext<DialogContextValue | undefined>(undefined); const DialogContext = createContext<DialogContextValue | undefined>(undefined);
function Dialog({ children }: { children: ReactNode }) { function Dialog({ children }: { children: ReactNode }) {
const [outerOpen, setOuterOpen] = useState(false); const [outerOpen, setOuterOpen] = useState(false);
const [innerOpen, setInnerOpen] = useState(false); const [innerOpen, setInnerOpen] = useState(false);
return ( return (
<DialogContext.Provider value={{ innerOpen, setInnerOpen }}> <DialogContext.Provider value={{ innerOpen, setInnerOpen }}>
<DialogPrimitive.Root open={outerOpen} onOpenChange={setOuterOpen}> <DialogPrimitive.Root open={outerOpen} onOpenChange={setOuterOpen}>
{children} {children}
</DialogPrimitive.Root> </DialogPrimitive.Root>
</DialogContext.Provider> </DialogContext.Provider>
); );
} }
const DialogTrigger = DialogPrimitive.Trigger; const DialogTrigger = DialogPrimitive.Trigger;
@ -44,301 +44,301 @@ const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close; const DialogClose = DialogPrimitive.Close;
const DialogOverlay = forwardRef< const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>, ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
"bg-background/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm", "bg-background/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 backdrop-blur-sm",
className, className,
)} )}
{...props} {...props}
/> />
)); ));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = forwardRef< const DialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>, ElementRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content> ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => { >(({ className, children, ...props }, ref) => {
const context = useContext(DialogContext); const context = useContext(DialogContext);
if (!context) throw new Error("DialogContent must be used within a Dialog"); if (!context) throw new Error("DialogContent must be used within a Dialog");
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:rounded-lg", "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:rounded-lg",
context.innerOpen && "translate-y-[-55%] scale-[0.97]", context.innerOpen && "translate-y-[-55%] scale-[0.97]",
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogClose className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <DialogClose className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<X className="size-4" /> <X className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogClose> </DialogClose>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
); );
}); });
DialogContent.displayName = DialogPrimitive.Content.displayName; DialogContent.displayName = DialogPrimitive.Content.displayName;
function InnerDialog({ children }: { children: ReactNode }) { function InnerDialog({ children }: { children: ReactNode }) {
const context = useContext(DialogContext); const context = useContext(DialogContext);
if (!context) throw new Error("InnerDialog must be used within a Dialog"); if (!context) throw new Error("InnerDialog must be used within a Dialog");
useEffect(() => { useEffect(() => {
const handleEscapeKeyDown = (event: KeyboardEvent) => { const handleEscapeKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && context.innerOpen) { if (event.key === "Escape" && context.innerOpen) {
context.setInnerOpen(false); context.setInnerOpen(false);
event.stopPropagation(); event.stopPropagation();
} }
}; };
document.addEventListener("keydown", handleEscapeKeyDown); document.addEventListener("keydown", handleEscapeKeyDown);
return () => { return () => {
document.removeEventListener("keydown", handleEscapeKeyDown); document.removeEventListener("keydown", handleEscapeKeyDown);
}; };
}, [context.innerOpen, context.setInnerOpen]); }, [context.innerOpen, context.setInnerOpen]);
return ( return (
<DialogPrimitive.Root <DialogPrimitive.Root
open={context.innerOpen} open={context.innerOpen}
onOpenChange={context.setInnerOpen} onOpenChange={context.setInnerOpen}
> >
{children} {children}
</DialogPrimitive.Root> </DialogPrimitive.Root>
); );
} }
const InnerDialogTrigger = DialogPrimitive.Trigger; const InnerDialogTrigger = DialogPrimitive.Trigger;
const InnerDialogClose = DialogPrimitive.Close; const InnerDialogClose = DialogPrimitive.Close;
interface InnerDialogContentProps interface InnerDialogContentProps
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> { extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
position?: "default" | "bottom" | "top" | "left" | "right"; position?: "default" | "bottom" | "top" | "left" | "right";
draggable?: boolean; draggable?: boolean;
} }
const InnerDialogContent = forwardRef< const InnerDialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>, ElementRef<typeof DialogPrimitive.Content>,
InnerDialogContentProps InnerDialogContentProps
>( >(
( (
{ className, children, position = "default", draggable = false, ...props }, { className, children, position = "default", draggable = false, ...props },
ref, ref,
) => { ) => {
const context = useContext(DialogContext); const context = useContext(DialogContext);
if (!context) if (!context)
throw new Error("InnerDialogContent must be used within a Dialog"); throw new Error("InnerDialogContent must be used within a Dialog");
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0); const [startY, setStartY] = useState(0);
const [currentY, setCurrentY] = useState(0); const [currentY, setCurrentY] = useState(0);
const [isClosingByDrag, setIsClosingByDrag] = useState(false); const [isClosingByDrag, setIsClosingByDrag] = useState(false);
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => { useEffect(() => {
if (context.innerOpen) { if (context.innerOpen) {
setCurrentY(0); setCurrentY(0);
setIsClosingByDrag(false); setIsClosingByDrag(false);
} }
}, [context.innerOpen]); }, [context.innerOpen]);
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => { const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (!draggable) return; if (!draggable) return;
setIsDragging(true); setIsDragging(true);
setStartY(e.clientY - currentY); setStartY(e.clientY - currentY);
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
}; };
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => { const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!isDragging || !draggable) return; if (!isDragging || !draggable) return;
const newY = e.clientY - startY; const newY = e.clientY - startY;
setCurrentY(newY > 0 ? newY : 0); setCurrentY(newY > 0 ? newY : 0);
}; };
const handlePointerUp = () => { const handlePointerUp = () => {
if (!draggable) return; if (!draggable) return;
setIsDragging(false); setIsDragging(false);
if (currentY > (contentRef.current?.offsetHeight || 0) / 2) { if (currentY > (contentRef.current?.offsetHeight || 0) / 2) {
setIsClosingByDrag(true); setIsClosingByDrag(true);
context.setInnerOpen(false); context.setInnerOpen(false);
} else { } else {
setCurrentY(0); setCurrentY(0);
} }
}; };
return ( return (
<DialogPortal> <DialogPortal>
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
style={{ style={{
transform: `translate(-50%, calc(-50% + ${currentY}px))`, transform: `translate(-50%, calc(-50% + ${currentY}px))`,
transition: isDragging ? "none" : "transform 0.3s ease-out", transition: isDragging ? "none" : "transform 0.3s ease-out",
}} }}
className={cn( className={cn(
"bg-background fixed top-[50%] left-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-45%] gap-4 rounded-lg border p-6 shadow-lg duration-200", "bg-background fixed top-[50%] left-[50%] z-[60] grid w-full max-w-lg translate-x-[-50%] translate-y-[-45%] gap-4 rounded-lg border p-6 shadow-lg duration-200",
isClosingByDrag isClosingByDrag
? "data-[state=closed]:fade-out-0 data-[state=closed]:animate-none" ? "data-[state=closed]:fade-out-0 data-[state=closed]:animate-none"
: "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]", : "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
position === "default" && position === "default" &&
"data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]", "data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
position === "bottom" && position === "bottom" &&
"data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-bottom-full", "data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-bottom-full",
position === "top" && position === "top" &&
"data-[state=closed]:slide-out-to-top-full data-[state=open]:slide-in-from-top-full", "data-[state=closed]:slide-out-to-top-full data-[state=open]:slide-in-from-top-full",
position === "left" && position === "left" &&
"data-[state=closed]:slide-out-to-left-full data-[state=open]:slide-in-from-left-full", "data-[state=closed]:slide-out-to-left-full data-[state=open]:slide-in-from-left-full",
position === "right" && position === "right" &&
"data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full", "data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
draggable && "", draggable && "",
className, className,
)} )}
{...props} {...props}
> >
<div ref={contentRef}>{children}</div> <div ref={contentRef}>{children}</div>
<InnerDialogClose className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none"> <InnerDialogClose className="ring-offset-background data-[state=open]:bg-accent data-[state=open]:text-muted-foreground focus:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<X className="size-4" /> <X className="size-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</InnerDialogClose> </InnerDialogClose>
</DialogPrimitive.Content> </DialogPrimitive.Content>
</DialogPortal> </DialogPortal>
); );
}, },
); );
InnerDialogContent.displayName = "InnerDialogContent"; InnerDialogContent.displayName = "InnerDialogContent";
const InnerDialogHeader = ({ const InnerDialogHeader = ({
className, className,
...props ...props
}: HTMLAttributes<HTMLDivElement>) => ( }: HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className, className,
)} )}
{...props} {...props}
/> />
); );
InnerDialogHeader.displayName = "InnerDialogHeader"; InnerDialogHeader.displayName = "InnerDialogHeader";
const InnerDialogFooter = ({ const InnerDialogFooter = ({
className, className,
...props ...props
}: HTMLAttributes<HTMLDivElement>) => ( }: HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)} className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
{...props} {...props}
/> />
); );
InnerDialogFooter.displayName = "InnerDialogFooter"; InnerDialogFooter.displayName = "InnerDialogFooter";
const InnerDialogTitle = forwardRef< const InnerDialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>, ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title> ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg leading-none font-semibold tracking-tight", "text-lg leading-none font-semibold tracking-tight",
className, className,
)} )}
{...props} {...props}
/> />
)); ));
InnerDialogTitle.displayName = "InnerDialogTitle"; InnerDialogTitle.displayName = "InnerDialogTitle";
const InnerDialogDescription = forwardRef< const InnerDialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>, ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description> ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
)); ));
InnerDialogDescription.displayName = "InnerDialogDescription"; InnerDialogDescription.displayName = "InnerDialogDescription";
const DialogHeader = ({ const DialogHeader = ({
className, className,
...props ...props
}: HTMLAttributes<HTMLDivElement>) => ( }: HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn( className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left", "flex flex-col space-y-1.5 text-center sm:text-left",
className, className,
)} )}
{...props} {...props}
/> />
); );
DialogHeader.displayName = "DialogHeader"; DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({ const DialogFooter = ({
className, className,
...props ...props
}: HTMLAttributes<HTMLDivElement>) => ( }: HTMLAttributes<HTMLDivElement>) => (
<div <div
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)} className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
{...props} {...props}
/> />
); );
DialogFooter.displayName = "DialogFooter"; DialogFooter.displayName = "DialogFooter";
const DialogTitle = forwardRef< const DialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>, ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title> ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn( className={cn(
"text-lg leading-none font-semibold tracking-tight", "text-lg leading-none font-semibold tracking-tight",
className, className,
)} )}
{...props} {...props}
/> />
)); ));
DialogTitle.displayName = DialogPrimitive.Title.displayName; DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = forwardRef< const DialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>, ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description> ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn("text-muted-foreground text-sm", className)} className={cn("text-muted-foreground text-sm", className)}
{...props} {...props}
/> />
)); ));
DialogDescription.displayName = DialogPrimitive.Description.displayName; DialogDescription.displayName = DialogPrimitive.Description.displayName;
export type { InnerDialogContentProps }; export type { InnerDialogContentProps };
export { export {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogFooter, DialogFooter,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogClose, DialogClose,
InnerDialog, InnerDialog,
InnerDialogTrigger, InnerDialogTrigger,
InnerDialogContent, InnerDialogContent,
InnerDialogHeader, InnerDialogHeader,
InnerDialogFooter, InnerDialogFooter,
InnerDialogTitle, InnerDialogTitle,
InnerDialogDescription, InnerDialogDescription,
InnerDialogClose, InnerDialogClose,
DialogPortal, DialogPortal,
DialogOverlay, DialogOverlay,
}; };

View file

@ -1,49 +1,49 @@
"use client"; "use client";
import { cn } from "~/lib/utils";
import { motion, useInView } from "framer-motion"; import { motion, useInView } from "framer-motion";
import * as React from "react"; import * as React from "react";
import { cn } from "~/lib/utils";
type TextStaggeredFadeProps = { type TextStaggeredFadeProps = {
text: string; text: string;
className?: string; className?: string;
hide?: boolean; hide?: boolean;
}; };
export const StaggeredFade: React.FC<TextStaggeredFadeProps> = ({ export const StaggeredFade: React.FC<TextStaggeredFadeProps> = ({
text, text,
className = "", className = "",
hide = false, hide = false,
}) => { }) => {
const variants = { const variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: (i: number) => ({ show: (i: number) => ({
y: 0, y: 0,
opacity: 1, opacity: 1,
transition: { delay: i * 0.07 }, transition: { delay: i * 0.07 },
}), }),
}; };
const letters = text.split(""); const letters = text.split("");
const ref = React.useRef(null); const ref = React.useRef(null);
const isInView = useInView(ref, { once: true }); const isInView = useInView(ref, { once: true });
return ( return (
<motion.h2 <motion.h2
ref={ref} ref={ref}
initial="hidden" initial="hidden"
animate={isInView ? (!hide ? "show" : "hidden") : ""} animate={isInView ? (!hide ? "show" : "hidden") : ""}
variants={variants} variants={variants}
viewport={{ once: true }} viewport={{ once: true }}
className={cn( className={cn(
"text-center text-xl font-bold tracking-tighter md:leading-[4rem]", "text-center text-xl font-bold tracking-tighter md:leading-[4rem]",
className, className,
)} )}
> >
{letters.map((word, i) => ( {letters.map((word, i) => (
<motion.span key={`${word}-${i}`} variants={variants} custom={i}> <motion.span key={`${word}-${i}`} variants={variants} custom={i}>
{word} {word}
</motion.span> </motion.span>
))} ))}
</motion.h2> </motion.h2>
); );
}; };

View file

@ -4,18 +4,18 @@ import { cn } from "src/lib/utils";
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>; type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>( const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => { ({ className, ...props }, ref) => {
return ( return (
<textarea <textarea
className={cn( className={cn(
`border-border bg-background hover:border-muted-foreground focus:border-foreground focus:ring-foreground flex min-h-[80px] w-full rounded-lg border px-3 py-2 shadow-sm outline-hidden transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-500 dark:placeholder:text-neutral-400 dark:hover:border-neutral-400 dark:focus:border-transparent`, `border-border bg-background hover:border-muted-foreground focus:border-foreground focus:ring-foreground flex min-h-[80px] w-full rounded-lg border px-3 py-2 shadow-sm outline-hidden transition-all duration-200 disabled:cursor-not-allowed disabled:opacity-50 dark:border-neutral-500 dark:placeholder:text-neutral-400 dark:hover:border-neutral-400 dark:focus:border-transparent`,
className, className,
)} )}
ref={ref} ref={ref}
{...props} {...props}
/> />
); );
}, },
); );
Textarea.displayName = "Textarea"; Textarea.displayName = "Textarea";

View file

@ -2,17 +2,17 @@ import { MissingFile } from "~/components/svgs";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
export const DocNotFoundPage = () => { export const DocNotFoundPage = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="flex min-h-[60vh] w-full items-center justify-center"> <div className="flex min-h-[60vh] w-full items-center justify-center">
<div className="max-w-lg space-y-5 rounded-xl border bg-white p-10 text-center shadow-lg"> <div className="max-w-lg space-y-5 rounded-xl border bg-white p-10 text-center shadow-lg">
<MissingFile className="mx-auto mb-4 size-32 fill-indigo-600" /> <MissingFile className="mx-auto mb-4 size-32 fill-indigo-600" />
<h1 className="mb-2 text-3xl font-extrabold text-indigo-600"> <h1 className="mb-2 text-3xl font-extrabold text-indigo-600">
{t.doc_not_found.titolo} {t.doc_not_found.titolo}
</h1> </h1>
<p className="text-muted-foreground">{t.doc_not_found.sottotitolo}</p> <p className="text-muted-foreground">{t.doc_not_found.sottotitolo}</p>
<p className="text-muted-foreground">{t.doc_not_found.CTA}</p> <p className="text-muted-foreground">{t.doc_not_found.CTA}</p>
</div> </div>
</div> </div>
); );
}; };

View file

@ -1,191 +1,192 @@
import { Plus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Separator } from "~/components/ui/separator";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import type { ChatsChatid } from "~/schemas/public/Chats"; import type { ChatsChatid } from "~/schemas/public/Chats";
import { api } from "~/utils/api";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
import type { EtichetteIdEtichetta } from "~/schemas/public/Etichette"; import type { EtichetteIdEtichetta } from "~/schemas/public/Etichette";
import { Button } from "~/components/ui/button"; import { api } from "~/utils/api";
import { Separator } from "~/components/ui/separator";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "~/components/ui/select";
import { Plus } from "lucide-react";
export const EtichetteDisplayer = ({ export const EtichetteDisplayer = ({
chatId, chatId,
className, className,
}: { }: {
chatId: ChatsChatid; chatId: ChatsChatid;
className?: string; className?: string;
}) => { }) => {
const { data, isLoading } = api.chat.getChatEtichette.useQuery({ const { data, isLoading } = api.chat.getChatEtichette.useQuery({
chatId, chatId,
}); });
const [openEdit, setOpenEdit] = useState(false); const [openEdit, setOpenEdit] = useState(false);
const handleOpen = (status: boolean) => { const handleOpen = (status: boolean) => {
setOpenEdit(status); setOpenEdit(status);
}; };
if ((!isLoading && !data) || !data) return null; if ((!isLoading && !data) || !data) return null;
return ( return (
<div className={cn("flex flex-wrap gap-2", className)}> <div className={cn("flex flex-wrap gap-2", className)}>
{data.map((etichetta) => ( {data.map((etichetta) => (
<Etichetta <Etichetta
key={etichetta.id_etichetta} key={etichetta.id_etichetta}
title={etichetta.title} title={etichetta.title}
color_hex={etichetta.color_hex} color_hex={etichetta.color_hex}
className="text-xs" className="text-xs"
/> />
))} ))}
<button <button
onClick={() => setOpenEdit(true)} type="button"
className="flex items-center justify-center rounded-lg border p-1" onClick={() => setOpenEdit(true)}
> className="flex items-center justify-center rounded-lg border p-1"
<Plus className="size-4" /> >
</button> <Plus className="size-4" />
<EtichetteModal </button>
chatId={chatId} <EtichetteModal
open={openEdit} chatId={chatId}
onOpenChange={handleOpen} open={openEdit}
/> onOpenChange={handleOpen}
</div> />
); </div>
);
}; };
export const EtichetteModal = ({ export const EtichetteModal = ({
chatId, chatId,
open, open,
onOpenChange, onOpenChange,
}: { }: {
chatId: ChatsChatid; chatId: ChatsChatid;
open: boolean; open: boolean;
onOpenChange: (status: boolean) => void; onOpenChange: (status: boolean) => void;
}) => { }) => {
const [selected, setSelected] = useState<EtichetteIdEtichetta | null>(null); const [selected, setSelected] = useState<EtichetteIdEtichetta | null>(null);
const utils = api.useUtils(); const utils = api.useUtils();
const { data } = api.chat.getChatEtichette.useQuery({ chatId }); const { data } = api.chat.getChatEtichette.useQuery({ chatId });
const { data: etichette } = api.settings.getAllEtichette.useQuery(); const { data: etichette } = api.settings.getAllEtichette.useQuery();
const { mutate: remove } = api.chat.removeEtichettaFromChat.useMutation({ const { mutate: remove } = api.chat.removeEtichettaFromChat.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.chat.getChatEtichette.invalidate({ chatId }); await utils.chat.getChatEtichette.invalidate({ chatId });
await utils.chat.getActiveChats.invalidate(); await utils.chat.getActiveChats.invalidate();
}, },
}); });
const { mutate: add } = api.chat.addEtichettaToChat.useMutation({ const { mutate: add } = api.chat.addEtichettaToChat.useMutation({
onSuccess: async () => { onSuccess: async () => {
await utils.chat.getChatEtichette.invalidate({ chatId }); await utils.chat.getChatEtichette.invalidate({ chatId });
await utils.chat.getActiveChats.invalidate(); await utils.chat.getActiveChats.invalidate();
}, },
}); });
if (!data) return null; if (!data) return null;
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Etichette Attive</DialogTitle> <DialogTitle>Etichette Attive</DialogTitle>
<DialogDescription className="sr-only">Attive</DialogDescription> <DialogDescription className="sr-only">Attive</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-5"> <div className="space-y-5">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{data.map((etichetta, idx) => ( {data.map((etichetta, idx) => (
<div key={idx} className="flex gap-2"> <div key={idx} className="flex gap-2">
<Etichetta <Etichetta
title={etichetta.title} title={etichetta.title}
color_hex={etichetta.color_hex} color_hex={etichetta.color_hex}
/> />
<Button <Button
onClick={() => { onClick={() => {
remove({ chatId, etichettaId: etichetta.id_etichetta }); remove({ chatId, etichettaId: etichetta.id_etichetta });
}} }}
> >
Rimuovi Rimuovi
</Button> </Button>
</div> </div>
))} ))}
</div> </div>
<Separator /> <Separator />
<div className="flex gap-2"> <div className="flex gap-2">
<Select <Select
onValueChange={(value) => onValueChange={(value) =>
setSelected(parseInt(value) as EtichetteIdEtichetta) setSelected(parseInt(value) as EtichetteIdEtichetta)
} }
> >
<SelectTrigger className="w-[180px]"> <SelectTrigger className="w-[180px]">
<SelectValue placeholder="Seleziona" /> <SelectValue placeholder="Seleziona" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{etichette?.map((etichetta, idx) => ( {etichette?.map((etichetta, idx) => (
<SelectItem key={idx} value={String(etichetta.id_etichetta)}> <SelectItem key={idx} value={String(etichetta.id_etichetta)}>
{etichetta.title} {etichetta.title}
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Button <Button
onClick={() => { onClick={() => {
if (selected) { if (selected) {
add({ chatId, etichettaId: selected }); add({ chatId, etichettaId: selected });
} }
}} }}
> >
Aggiungi Aggiungi
</Button> </Button>
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
}; };
export const Etichetta = ({ export const Etichetta = ({
title, title,
color_hex, color_hex,
className, className,
}: { }: {
title: string; title: string;
color_hex: string; color_hex: string;
className?: string; className?: string;
}) => { }) => {
return ( return (
<span <span
className={cn( className={cn(
"flex w-fit items-center justify-center rounded-md px-1.5 text-sm whitespace-nowrap select-none", "flex w-fit items-center justify-center rounded-md px-1.5 text-sm whitespace-nowrap select-none",
className, className,
)} )}
style={{ style={{
backgroundColor: color_hex, backgroundColor: color_hex,
color: colorIsDarkSimple(color_hex) ? "#FFFFFF" : "#000000", color: colorIsDarkSimple(color_hex) ? "#FFFFFF" : "#000000",
}} }}
> >
{title} {title}
</span> </span>
); );
}; };
function hexToRgb(hex: string): { r: number; g: number; b: number } { function hexToRgb(hex: string): { r: number; g: number; b: number } {
const bigint = parseInt(hex.slice(1), 16); const bigint = parseInt(hex.slice(1), 16);
return { return {
r: (bigint >> 16) & 255, r: (bigint >> 16) & 255,
g: (bigint >> 8) & 255, g: (bigint >> 8) & 255,
b: bigint & 255, b: bigint & 255,
}; };
} }
export function colorIsDarkSimple(color: string) { export function colorIsDarkSimple(color: string) {
if (color == "#FFFFFF") return false; if (color === "#FFFFFF") return false;
const { r, g, b } = hexToRgb(color); const { r, g, b } = hexToRgb(color);
return r * 0.299 + g * 0.587 + b * 0.114 <= 186; return r * 0.299 + g * 0.587 + b * 0.114 <= 186;
} }

View file

@ -1,38 +1,39 @@
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useTranslation } from "~/providers/I18nProvider";
import { HomeIcon } from "~/components/IconComponents"; import { HomeIcon } from "~/components/IconComponents";
import { useTranslation } from "~/providers/I18nProvider";
export default function FailedAnnuncioLoading() { export default function FailedAnnuncioLoading() {
const router = useRouter(); const router = useRouter();
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<main> <main>
<div className="mx-auto flex h-screen max-w-screen-xl items-center justify-start px-4 md:px-8"> <div className="mx-auto flex h-screen max-w-screen-xl items-center justify-start px-4 md:px-8">
<div className="mx-auto max-w-lg space-y-3 text-center"> <div className="mx-auto max-w-lg space-y-3 text-center">
<HomeIcon className="mx-auto size-20 text-rose-700" /> <HomeIcon className="mx-auto size-20 text-rose-700" />
<h3 className="text-accent-foreground text-4xl font-semibold sm:text-5xl"> <h3 className="text-accent-foreground text-4xl font-semibold sm:text-5xl">
{t.annuncio_load_fail.titolo} {t.annuncio_load_fail.titolo}
</h3> </h3>
<p className="text-neutral-600">{t.annuncio_load_fail.sottotitolo}</p> <p className="text-neutral-600">{t.annuncio_load_fail.sottotitolo}</p>
<div className="flex flex-wrap items-center justify-center gap-3"> <div className="flex flex-wrap items-center justify-center gap-3">
<button <button
onClick={() => { type="button"
router.back(); onClick={() => {
}} router.back();
className="block rounded-lg bg-rose-600 px-4 py-2 font-medium text-white duration-150 hover:bg-rose-500 active:bg-rose-700" }}
> className="block rounded-lg bg-rose-600 px-4 py-2 font-medium text-white duration-150 hover:bg-rose-500 active:bg-rose-700"
{t.annuncio_load_fail.CTA} >
</button> {t.annuncio_load_fail.CTA}
<Link </button>
href="/annunci" <Link
className="block rounded-lg border px-4 py-2 font-medium text-neutral-700 duration-150 hover:bg-neutral-50 active:bg-neutral-100" href="/annunci"
> className="block rounded-lg border px-4 py-2 font-medium text-neutral-700 duration-150 hover:bg-neutral-50 active:bg-neutral-100"
{t.annuncio_load_fail.home} >
</Link> {t.annuncio_load_fail.home}
</div> </Link>
</div> </div>
</div> </div>
</main> </div>
); </main>
);
} }

View file

@ -3,104 +3,98 @@ import BlurryDivider from "~/components/blurry_divider";
import { LogoSvg } from "~/components/svgs"; import { LogoSvg } from "~/components/svgs";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
export const Footer = () => { export const Footer = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="mt-auto"> <div className="mt-auto">
<footer <footer className="bg-muted text-muted-foreground bottom-0 z-40 w-full">
aria-label="Site Footer" <div className="mx-auto max-w-6xl px-4 py-6 sm:px-6 lg:px-8">
className="bg-muted text-muted-foreground bottom-0 z-40 w-full" <div className="text-center">
> <Link
<div className="mx-auto max-w-6xl px-4 py-6 sm:px-6 lg:px-8"> href="/"
<div className="text-center"> aria-label="footerLogo"
<Link className="flex items-center justify-center text-3xl font-bold tracking-wide antialiased"
href="/" >
aria-label="footerLogo" <LogoSvg className="h-12 w-auto [&>g]:fill-red-500 dark:[&>g]:fill-neutral-100 [&>text]:fill-neutral-600 [&>text]:stroke-neutral-600 dark:[&>text]:fill-neutral-100 dark:[&>text]:stroke-neutral-100" />
className="flex items-center justify-center text-3xl font-bold tracking-wide antialiased" </Link>
> </div>
<LogoSvg className="h-12 w-auto [&>g]:fill-red-500 dark:[&>g]:fill-neutral-100 [&>text]:fill-neutral-600 [&>text]:stroke-neutral-600 dark:[&>text]:fill-neutral-100 dark:[&>text]:stroke-neutral-100" />
</Link>
</div>
<nav aria-label="Footer Nav" className="mt-6"> <nav aria-label="Footer Nav" className="mt-6">
<ul className="flex flex-wrap justify-center gap-6 md:gap-8 lg:gap-12"> <ul className="flex flex-wrap justify-center gap-6 md:gap-8 lg:gap-12">
<li> <li>
<Link <Link
className="text-sm" className="text-sm"
href="/guida" href="/guida"
aria-label="footerGuida" aria-label="footerGuida"
> >
{t.footer.Guida} {t.footer.Guida}
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
className="text-sm" className="text-sm"
href="/chi-siamo" href="/chi-siamo"
aria-label="footerChiSiamo" aria-label="footerChiSiamo"
> >
{t.footer["Chi Siamo"]} {t.footer["Chi Siamo"]}
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
className="text-sm" className="text-sm"
href="/termini-condizioni" href="/termini-condizioni"
aria-label="footerTerminiCondizioni" aria-label="footerTerminiCondizioni"
> >
{t.termini_condizioni_title} {t.termini_condizioni_title}
</Link> </Link>
</li> </li>
<li> <li>
<Link <Link
className="text-sm" className="text-sm"
href="/privacy-policy" href="/privacy-policy"
aria-label="footerPrivacyPolicy" aria-label="footerPrivacyPolicy"
> >
{t.privacy_policy_title} {t.privacy_policy_title}
</Link> </Link>
</li> </li>
</ul> </ul>
</nav> </nav>
<div className="py-4"> <div className="py-4">
<BlurryDivider /> <BlurryDivider />
</div> </div>
<div className="text-center text-xs"> <div className="text-center text-xs">
<p>{t.footer.endtxt}</p> <p>{t.footer.endtxt}</p>
<p>{t.footer.endtxt2}</p> <p>{t.footer.endtxt2}</p>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
); );
}; };
export const MiniFooter = () => { export const MiniFooter = () => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="mt-auto flex w-full"> <div className="mt-auto flex w-full">
<footer <footer className="dark:bg-primary z-40 w-full bg-neutral-100 text-neutral-700 dark:text-white">
aria-label="Site Footer" <div className="mx-auto max-w-5xl px-2 py-2 sm:px-6 lg:px-8">
className="dark:bg-primary z-40 w-full bg-neutral-100 text-neutral-700 dark:text-white" <div className="text-center">
> <Link
<div className="mx-auto max-w-5xl px-2 py-2 sm:px-6 lg:px-8"> aria-label="footerLogo"
<div className="text-center"> href="/"
<Link className="text-2xl font-bold tracking-wide antialiased"
aria-label="footerLogo" >
href="/" Infoalloggi.it
className="text-2xl font-bold tracking-wide antialiased" </Link>
> </div>
Infoalloggi.it
</Link>
</div>
<div className="mt-2 text-center text-xs"> <div className="mt-2 text-center text-xs">
<p>{t.footer.endtxt}</p> <p>{t.footer.endtxt}</p>
<p>{t.footer.endtxt2}</p> <p>{t.footer.endtxt2}</p>
</div> </div>
</div> </div>
</footer> </footer>
</div> </div>
); );
}; };

View file

@ -2,10 +2,10 @@
import { ASvg } from "~/components/svgs"; import { ASvg } from "~/components/svgs";
export const LoadingPage = () => { export const LoadingPage = () => {
return ( return (
<div className="flex h-full max-h-full w-full animate-pulse items-center justify-center py-20"> <div className="flex h-full max-h-full w-full animate-pulse items-center justify-center py-20">
<div className="relative flex size-40 animate-spin items-center justify-center rounded-full border-8 border-gray-300 border-t-red-400 text-4xl text-red-400"></div> <div className="relative flex size-40 animate-spin items-center justify-center rounded-full border-8 border-gray-300 border-t-red-400 text-4xl text-red-400"></div>
<ASvg className="absolute size-24 -translate-y-2" /> <ASvg className="absolute size-24 -translate-y-2" />
</div> </div>
); );
}; };

View file

@ -1,202 +1,202 @@
"use client"; "use client";
import { import {
MapContainer, Circle,
TileLayer, MapContainer,
Marker, Marker,
Popup, Popup,
Circle, TileLayer,
useMapEvents, useMapEvents,
} from "react-leaflet"; } from "react-leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css"; import "leaflet-defaulticon-compatibility/dist/leaflet-defaulticon-compatibility.css";
import "leaflet-defaulticon-compatibility"; import "leaflet-defaulticon-compatibility";
import { cn } from "~/lib/utils";
import MapMarker from "~/components/map/map_marker";
import { Marker as CustomMarker } from "~/components/map/custom_marker";
import { Home } from "lucide-react"; import { Home } from "lucide-react";
import { Marker as CustomMarker } from "~/components/map/custom_marker";
import MapMarker from "~/components/map/map_marker";
import { cn } from "~/lib/utils";
type LatLon = { type LatLon = {
lat: number; lat: number;
lng: number; lng: number;
alt?: number; alt?: number;
}; };
type posData = { type posData = {
pos: LatLon; pos: LatLon;
popupTxt?: string; popupTxt?: string;
onClick?: () => void; onClick?: () => void;
markerTxt?: string; markerTxt?: string;
}; };
type GroupProps = { type GroupProps = {
isGroup: true; isGroup: true;
groupData: posData[]; groupData: posData[];
}; };
type SingleProps = { type SingleProps = {
isGroup?: false; isGroup?: false;
groupData?: never; groupData?: never;
}; };
type MapProps = { type MapProps = {
posix: posData; posix: posData;
autocenter?: boolean; autocenter?: boolean;
zoom?: number; zoom?: number;
markerType: "precise" | "circle" | "price" | "home"; markerType: "precise" | "circle" | "price" | "home";
markerRadius?: number; markerRadius?: number;
scrollWheelZoom?: boolean; scrollWheelZoom?: boolean;
width?: string; width?: string;
height?: string; height?: string;
onMapClick?: () => void; onMapClick?: () => void;
} & (GroupProps | SingleProps); } & (GroupProps | SingleProps);
const defaults = { const defaults = {
zoom: 15, zoom: 15,
width: "500px", width: "500px",
height: "500px", height: "500px",
}; };
const MedianPoint = (pos: posData[]) => { const MedianPoint = (pos: posData[]) => {
const lat = pos.map((p) => p.pos.lat).reduce((a, b) => a + b) / pos.length; const lat = pos.map((p) => p.pos.lat).reduce((a, b) => a + b) / pos.length;
const lng = pos.map((p) => p.pos.lng).reduce((a, b) => a + b) / pos.length; const lng = pos.map((p) => p.pos.lng).reduce((a, b) => a + b) / pos.length;
return { lat, lng }; return { lat, lng };
}; };
const Map = ({ const MapComp = ({
posix, posix,
autocenter, autocenter,
zoom = defaults.zoom, zoom = defaults.zoom,
markerType, markerType,
markerRadius, markerRadius,
scrollWheelZoom = false, scrollWheelZoom = false,
width = defaults.width, width = defaults.width,
height = defaults.height, height = defaults.height,
isGroup, isGroup,
groupData, groupData,
onMapClick, onMapClick,
}: MapProps) => { }: MapProps) => {
const APIs = () => { const APIs = () => {
useMapEvents({ useMapEvents({
click: () => { click: () => {
if (onMapClick) { if (onMapClick) {
onMapClick(); onMapClick();
} }
}, },
}); });
return null; return null;
}; };
const MarkerComp = ({ pos }: { pos: posData }) => { const MarkerComp = ({ pos }: { pos: posData }) => {
switch (markerType) { switch (markerType) {
case "price": case "price":
return ( return (
<CustomMarker <CustomMarker
position={pos.pos} position={pos.pos}
eventHandlers={pos.onClick && { click: pos.onClick }} eventHandlers={pos.onClick && { click: pos.onClick }}
title={pos.markerTxt} title={pos.markerTxt}
icon={ icon={
<MapMarker <MapMarker
label={ label={
<span className="inline-block whitespace-nowrap"> <span className="inline-block whitespace-nowrap">
{pos.markerTxt} {pos.markerTxt}
</span> </span>
} }
color="primary" color="primary"
size="sm" size="sm"
/> />
} }
> >
{pos.popupTxt && <Popup>{pos.popupTxt}</Popup>} {pos.popupTxt && <Popup>{pos.popupTxt}</Popup>}
</CustomMarker> </CustomMarker>
); );
case "precise": case "precise":
return ( return (
<Marker <Marker
position={pos.pos} position={pos.pos}
eventHandlers={pos.onClick && { click: pos.onClick }} eventHandlers={pos.onClick && { click: pos.onClick }}
title={pos.markerTxt} title={pos.markerTxt}
> >
{pos.popupTxt && <Popup>{pos.popupTxt}</Popup>} {pos.popupTxt && <Popup>{pos.popupTxt}</Popup>}
</Marker> </Marker>
); );
case "circle": case "circle":
return ( return (
<Circle <Circle
center={pos.pos} center={pos.pos}
pathOptions={{ color: "red" }} pathOptions={{ color: "red" }}
radius={markerRadius || 100} radius={markerRadius || 100}
eventHandlers={pos.onClick && { click: pos.onClick }} eventHandlers={pos.onClick && { click: pos.onClick }}
> >
{pos.popupTxt && <Popup>{pos.popupTxt}</Popup>} {pos.popupTxt && <Popup>{pos.popupTxt}</Popup>}
</Circle> </Circle>
); );
case "home": case "home":
return ( return (
<> <>
<Circle <Circle
center={pos.pos} center={pos.pos}
pathOptions={{ color: "#ec4899" }} pathOptions={{ color: "#ec4899" }}
fillOpacity={1} fillOpacity={1}
radius={markerRadius || 100} radius={markerRadius || 100}
eventHandlers={pos.onClick && { click: pos.onClick }} eventHandlers={pos.onClick && { click: pos.onClick }}
> >
{pos.popupTxt && <Popup>{pos.popupTxt}</Popup>} {pos.popupTxt && <Popup>{pos.popupTxt}</Popup>}
</Circle> </Circle>
<CustomMarker <CustomMarker
position={pos.pos} position={pos.pos}
eventHandlers={pos.onClick && { click: pos.onClick }} eventHandlers={pos.onClick && { click: pos.onClick }}
title={pos.markerTxt} title={pos.markerTxt}
icon={ icon={
<div className="relative flex size-10 items-center justify-center rounded-full bg-pink-500 p-2"> <div className="relative flex size-10 items-center justify-center rounded-full bg-pink-500 p-2">
<Home className="absolute size-3/4 stroke-white" /> <Home className="absolute size-3/4 stroke-white" />
</div> </div>
} }
> >
{pos.popupTxt && <Popup>{pos.popupTxt}</Popup>} {pos.popupTxt && <Popup>{pos.popupTxt}</Popup>}
</CustomMarker> </CustomMarker>
</> </>
); );
} }
}; };
const hashKey = JSON.stringify( const hashKey = JSON.stringify(
groupData ? groupData.map((p) => p.pos) : posix.pos, groupData ? groupData.map((p) => p.pos) : posix.pos,
); );
return ( return (
<MapContainer <MapContainer
key={hashKey} key={hashKey}
center={ center={
autocenter ? MedianPoint(isGroup ? groupData : [posix]) : posix.pos autocenter ? MedianPoint(isGroup ? groupData : [posix]) : posix.pos
} }
zoom={zoom} zoom={zoom}
scrollWheelZoom={scrollWheelZoom} scrollWheelZoom={scrollWheelZoom}
style={{ style={{
height: height, height: height,
width: width, width: width,
borderRadius: "8px", borderRadius: "8px",
}} }}
className={cn("")} className={cn("")}
> >
<APIs /> <APIs />
<TileLayer <TileLayer
url="/api/tiles/{s}/{z}/{x}/{y}" url="/api/tiles/{s}/{z}/{x}/{y}"
//url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" //url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
/> />
{isGroup ? ( {isGroup ? (
groupData.map((pos, i) => <MarkerComp key={i} pos={pos} />) groupData.map((pos, i) => <MarkerComp key={i} pos={pos} />)
) : ( ) : (
<MarkerComp pos={posix} /> <MarkerComp pos={posix} />
)} )}
</MapContainer> </MapContainer>
); );
}; };
export default Map; export default MapComp;
/* /*
const PuntiInteresse: { const PuntiInteresse: {

View file

@ -1,49 +1,49 @@
import { import {
useState, type DivIconOptions,
useId, DomEvent,
useMemo, divIcon,
type ReactElement, type LeafletEventHandlerFn,
useCallback, type LeafletEventHandlerFnMap,
isValidElement, type Marker as LeafletMarker,
type ComponentType, type PointExpression,
useLayoutEffect, type PointTuple,
forwardRef, } from "leaflet";
useEffect, import {
type ComponentType,
forwardRef,
isValidElement,
type ReactElement,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useState,
} from "react"; } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { isValidElementType } from "react-is"; import { isValidElementType } from "react-is";
import { import {
Marker as ReactLeafletMarker, Marker as ReactLeafletMarker,
type MarkerProps as ReactLeafletMarkerProps, type MarkerProps as ReactLeafletMarkerProps,
} from "react-leaflet"; } from "react-leaflet";
import { import {
divIcon, createHtmlPortalNode,
DomEvent, InPortal,
type LeafletEventHandlerFn, OutPortal,
type LeafletEventHandlerFnMap,
type PointExpression,
type DivIconOptions,
type Marker as LeafletMarker,
type PointTuple,
} from "leaflet";
import { type SetRequired } from "type-fest";
import {
createHtmlPortalNode,
OutPortal,
InPortal,
} from "react-reverse-portal"; } from "react-reverse-portal";
import type { SetRequired } from "type-fest";
const getCoordsFromPointExpression = (expression?: PointExpression) => { const getCoordsFromPointExpression = (expression?: PointExpression) => {
if (!expression) return []; if (!expression) return [];
if (Array.isArray(expression)) { if (Array.isArray(expression)) {
return expression; return expression;
} else { } else {
return [expression.x, expression.y] as PointTuple; return [expression.x, expression.y] as PointTuple;
} }
}; };
const useCoordsFromPointExpression = (expression?: PointExpression) => const useCoordsFromPointExpression = (expression?: PointExpression) =>
useMemo(() => getCoordsFromPointExpression(expression), [expression]); useMemo(() => getCoordsFromPointExpression(expression), [expression]);
/** /**
* The possible options for the {@link ComponentMarkerOpts.layoutMode | `componentIconOpts.layoutMode`} option. * The possible options for the {@link ComponentMarkerOpts.layoutMode | `componentIconOpts.layoutMode`} option.
@ -51,272 +51,274 @@ const useCoordsFromPointExpression = (expression?: PointExpression) =>
type ComponentMarkerLayout = "fit-content" | "fit-parent"; type ComponentMarkerLayout = "fit-content" | "fit-parent";
type ComponentMarkerRootDivIconOpts = Omit< type ComponentMarkerRootDivIconOpts = Omit<
DivIconOptions, DivIconOptions,
| "html" | "html"
| "bgPos" | "bgPos"
| "shadowUrl" | "shadowUrl"
| "shadowSize" | "shadowSize"
| "shadowAnchor" | "shadowAnchor"
| "shadowRetinaUrl" | "shadowRetinaUrl"
| "iconUrl" | "iconUrl"
| "iconRetinaUrl" | "iconRetinaUrl"
>; >;
type RootDivOpt< type RootDivOpt<
RequiredKeys extends keyof ComponentMarkerRootDivIconOpts = never, RequiredKeys extends keyof ComponentMarkerRootDivIconOpts = never,
> = { > = {
/** /**
* The {@link DivIconOptions} (except for the `html` property and other properties that are not relevant in the context of a React driven marker) that are to be supplied to the `div` wrapper for the leaflet-managed wrapper of the React icon component. * The {@link DivIconOptions} (except for the `html` property and other properties that are not relevant in the context of a React driven marker) that are to be supplied to the `div` wrapper for the leaflet-managed wrapper of the React icon component.
* *
* When setting {@link ComponentMarkerOpts.layoutMode | `componentIconOpts.layoutMode`} as `'fit-parent'`, it is expected that {@link ComponentMarkerRootDivIconOpts.iconSize | `componentIconOpts.rootDivOpts.iconSize`} is set since this defines the dimensions of the component in this mode. * When setting {@link ComponentMarkerOpts.layoutMode | `componentIconOpts.layoutMode`} as `'fit-parent'`, it is expected that {@link ComponentMarkerRootDivIconOpts.iconSize | `componentIconOpts.rootDivOpts.iconSize`} is set since this defines the dimensions of the component in this mode.
* *
* @see {@link ComponentMarkerRootDivIconOpts} * @see {@link ComponentMarkerRootDivIconOpts}
**/ **/
rootDivOpts: SetRequired<ComponentMarkerRootDivIconOpts, RequiredKeys>; rootDivOpts: SetRequired<ComponentMarkerRootDivIconOpts, RequiredKeys>;
}; };
type CoreComponentMarkerOpts = { type CoreComponentMarkerOpts = {
/** /**
* `'fit-content'` disregards the `iconSize` passed to leaflet (defaults to `[0,0]`) and allows the React icon marker to be determined by the size of the provided component itself (which could be dynamic). Automatic alignment compensation is * `'fit-content'` disregards the `iconSize` passed to leaflet (defaults to `[0,0]`) and allows the React icon marker to be determined by the size of the provided component itself (which could be dynamic). Automatic alignment compensation is
* added to ensure the icon component stays centred on the X axis with the marker. * added to ensure the icon component stays centred on the X axis with the marker.
* *
* `'fit-parent'` will set the container of the component to be the same size as the `iconSize`. Typically, this is used alongside a static icon size that is passed via {@link ComponentMarkerRootDivIconOpts.iconSize | `componentIconOpts.rootDivOpts.iconSize`}. This setup may allow for more granular control over positioning and anchor configuration. The user supplied Icon component itself should use a width and height of `100%` to fill the container. * `'fit-parent'` will set the container of the component to be the same size as the `iconSize`. Typically, this is used alongside a static icon size that is passed via {@link ComponentMarkerRootDivIconOpts.iconSize | `componentIconOpts.rootDivOpts.iconSize`}. This setup may allow for more granular control over positioning and anchor configuration. The user supplied Icon component itself should use a width and height of `100%` to fill the container.
* *
* @defaultValue `"fit-content"` * @defaultValue `"fit-content"`
* @see {@link ComponentMarkerLayout} * @see {@link ComponentMarkerLayout}
*/ */
layoutMode?: ComponentMarkerLayout; layoutMode?: ComponentMarkerLayout;
/** /**
* If set to `true`, panning/scrolling the map will not be possible "through" the component marker. * If set to `true`, panning/scrolling the map will not be possible "through" the component marker.
* *
* This applies to the entire component marker. * This applies to the entire component marker.
* *
* @defaultValue `false` * @defaultValue `false`
*/ */
disableScrollPropagation?: boolean; disableScrollPropagation?: boolean;
/** /**
* If set to `true`, clicking on the component marker will not be captured by the underlying map. * If set to `true`, clicking on the component marker will not be captured by the underlying map.
* *
* This applies to the entire component marker. Note this will also disable the ability to activate native react-leaflet * This applies to the entire component marker. Note this will also disable the ability to activate native react-leaflet
* popups via clicking. * popups via clicking.
* *
* @defaultValue `false` * @defaultValue `false`
*/ */
disableClickPropagation?: boolean; disableClickPropagation?: boolean;
/** /**
* Enable or disable the console warning about the case where the {@link BaseMarkerProps.componentIconOpts | `componentIconOpts`} prop was set but the {@link BaseMarkerProps.icon | `icon`} prop * Enable or disable the console warning about the case where the {@link BaseMarkerProps.componentIconOpts | `componentIconOpts`} prop was set but the {@link BaseMarkerProps.icon | `icon`} prop
* is not a component. This would mean those options are unused. * is not a component. This would mean those options are unused.
* *
* @defaultValue `true` * @defaultValue `true`
* @see {@link BaseMarkerProps.componentIconOpts} * @see {@link BaseMarkerProps.componentIconOpts}
*/ */
unusedOptsWarning?: boolean; unusedOptsWarning?: boolean;
/** /**
* Enable or disable the console warning about the case where {@link ComponentMarkerOpts.layoutMode | `componentIconOpts.layoutMode`} was set to `fit-parent` but the {@link ComponentMarkerRootDivIconOpts.iconSize | `componentIconOpts.rootDivOpts.iconSize`} * Enable or disable the console warning about the case where {@link ComponentMarkerOpts.layoutMode | `componentIconOpts.layoutMode`} was set to `fit-parent` but the {@link ComponentMarkerRootDivIconOpts.iconSize | `componentIconOpts.rootDivOpts.iconSize`}
* has not been set. This would mean the size of the React component icon would not be visible. * has not been set. This would mean the size of the React component icon would not be visible.
* *
* @defaultValue `true` * @defaultValue `true`
* @see {@link ComponentMarkerOpts.rootDivOpts} * @see {@link ComponentMarkerOpts.rootDivOpts}
* @see {@link ComponentMarkerRootDivIconOpts.iconSize} * @see {@link ComponentMarkerRootDivIconOpts.iconSize}
*/ */
rootSizeWarning?: boolean; rootSizeWarning?: boolean;
}; };
type UnionKeys<T> = T extends T ? keyof T : never; type UnionKeys<T> = T extends T ? keyof T : never;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
// biome-ignore lint/suspicious/noExplicitAny: <intended>
type StrictUnionHelper<T, TAll> = T extends any type StrictUnionHelper<T, TAll> = T extends any
? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, undefined>> ? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, undefined>>
: never; : never;
type StrictUnion<T> = StrictUnionHelper<T, T>; type StrictUnion<T> = StrictUnionHelper<T, T>;
type ComponentMarkerOpts = StrictUnion< type ComponentMarkerOpts = StrictUnion<
| (CoreComponentMarkerOpts & { | (CoreComponentMarkerOpts & {
layoutMode?: "fit-content"; layoutMode?: "fit-content";
} & Partial<RootDivOpt>) } & Partial<RootDivOpt>)
| (CoreComponentMarkerOpts & { | (CoreComponentMarkerOpts & {
layoutMode: "fit-parent"; layoutMode: "fit-parent";
} & RootDivOpt<"iconSize">) } & RootDivOpt<"iconSize">)
>; >;
type BaseMarkerProps<AdditionalIconTypes = never> = Omit< type BaseMarkerProps<AdditionalIconTypes = never> = Omit<
ReactLeafletMarkerProps, ReactLeafletMarkerProps,
"icon" "icon"
> & { > & {
/** A {@link ReactElement} representing the Markers icon, or any type from [react-leaflet Marker](https://react-leaflet.js.org/docs/api-components/#marker) component. */ /** A {@link ReactElement} representing the Markers icon, or any type from [react-leaflet Marker](https://react-leaflet.js.org/docs/api-components/#marker) component. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: ReactElement<any> | AdditionalIconTypes;
/** // biome-ignore lint/suspicious/noExplicitAny: <intended>
* The {@link ComponentMarkerOpts}. These will not be effective if {@link BaseMarkerProps.icon | `icon`} is not set to a React Element or Component, and a warning will be given in the console. icon: ReactElement<any> | AdditionalIconTypes;
*
* @see {@link ComponentMarkerOpts.unusedOptsWarning} /**
*/ * The {@link ComponentMarkerOpts}. These will not be effective if {@link BaseMarkerProps.icon | `icon`} is not set to a React Element or Component, and a warning will be given in the console.
componentIconOpts?: ComponentMarkerOpts; *
* @see {@link ComponentMarkerOpts.unusedOptsWarning}
*/
componentIconOpts?: ComponentMarkerOpts;
}; };
type MarkerProps = BaseMarkerProps< type MarkerProps = BaseMarkerProps<
ReactLeafletMarkerProps["icon"] | ComponentType ReactLeafletMarkerProps["icon"] | ComponentType
>; >;
const DEFAULT_ICON_SIZE: PointExpression = [0, 0]; const DEFAULT_ICON_SIZE: PointExpression = [0, 0];
const ComponentMarker = forwardRef<LeafletMarker, BaseMarkerProps>( const ComponentMarker = forwardRef<LeafletMarker, BaseMarkerProps>(
( (
{ {
eventHandlers: providedEventHandlers, eventHandlers: providedEventHandlers,
icon: providedIcon, icon: providedIcon,
componentIconOpts: { componentIconOpts: {
layoutMode = "fit-content", layoutMode = "fit-content",
disableClickPropagation, disableClickPropagation,
disableScrollPropagation, disableScrollPropagation,
rootDivOpts, rootDivOpts,
rootSizeWarning, rootSizeWarning,
} = {}, } = {},
...otherProps ...otherProps
}, },
ref, ref,
) => { ) => {
const [markerRendered, setMarkerRendered] = useState(false); const [markerRendered, setMarkerRendered] = useState(false);
const [, setChangeCount] = useState(0); const [, setChangeCount] = useState(0);
const id = "marker-" + useId(); const id = `marker-${useId()}`;
const portalNode = useMemo( const portalNode = useMemo(
() => () =>
createHtmlPortalNode({ createHtmlPortalNode({
attributes: { attributes: {
"data-react-component-marker": "portal-parent", "data-react-component-marker": "portal-parent",
style: "width:100%;height:100%;", style: "width:100%;height:100%;",
}, },
}), }),
[], [],
); );
useEffect(() => { useEffect(() => {
if ( if (
rootSizeWarning !== false && rootSizeWarning !== false &&
layoutMode === "fit-parent" && layoutMode === "fit-parent" &&
rootDivOpts?.iconSize === undefined rootDivOpts?.iconSize === undefined
) { ) {
console.warn( console.warn(
`UNBOUND_FIT_PARENT `UNBOUND_FIT_PARENT
The 'componentIconOpts.rootDivOpts.iconSize' option was not set but 'componentIconOpts.layoutMode' was set to 'fit-parent'. This means your React component will not be properly bound by the parent. The 'componentIconOpts.rootDivOpts.iconSize' option was not set but 'componentIconOpts.layoutMode' was set to 'fit-parent'. This means your React component will not be properly bound by the parent.
To disable this warning set 'componentIconOpts.rootSizeWarning' to false.`, To disable this warning set 'componentIconOpts.rootSizeWarning' to false.`,
); );
} }
}, [layoutMode, rootSizeWarning, rootDivOpts?.iconSize]); }, [layoutMode, rootSizeWarning, rootDivOpts?.iconSize]);
const { const {
attribution, attribution,
className, className,
iconAnchor, iconAnchor,
iconSize = DEFAULT_ICON_SIZE, iconSize = DEFAULT_ICON_SIZE,
pane, pane,
popupAnchor, popupAnchor,
tooltipAnchor, tooltipAnchor,
} = rootDivOpts ?? {}; } = rootDivOpts ?? {};
const iconDeps = [ const iconDeps = [
id, id,
layoutMode, layoutMode,
attribution, attribution,
className, className,
pane, pane,
Boolean(disableClickPropagation), Boolean(disableClickPropagation),
Boolean(disableScrollPropagation), Boolean(disableScrollPropagation),
...useCoordsFromPointExpression(iconSize), ...useCoordsFromPointExpression(iconSize),
...useCoordsFromPointExpression(iconAnchor), ...useCoordsFromPointExpression(iconAnchor),
...useCoordsFromPointExpression(popupAnchor), ...useCoordsFromPointExpression(popupAnchor),
...useCoordsFromPointExpression(tooltipAnchor), ...useCoordsFromPointExpression(tooltipAnchor),
]; ];
const icon = useMemo(() => { const icon = useMemo(() => {
const parentStyles = const parentStyles =
layoutMode === "fit-content" layoutMode === "fit-content"
? "width: min-content; transform: translate(-50%, -50%)" ? "width: min-content; transform: translate(-50%, -50%)"
: "width: 100%; height: 100%"; : "width: 100%; height: 100%";
return divIcon({ return divIcon({
html: `<div data-react-component-marker="root" style="${parentStyles}" id="${id}"></div>`, html: `<div data-react-component-marker="root" style="${parentStyles}" id="${id}"></div>`,
...(iconSize ? { iconSize } : []), ...(iconSize ? { iconSize } : []),
...(iconAnchor ? { iconAnchor } : []), ...(iconAnchor ? { iconAnchor } : []),
...(popupAnchor ? { popupAnchor } : []), ...(popupAnchor ? { popupAnchor } : []),
...(tooltipAnchor ? { tooltipAnchor } : []), ...(tooltipAnchor ? { tooltipAnchor } : []),
pane, pane,
attribution, attribution,
className, className,
}); });
}, iconDeps); }, iconDeps);
useLayoutEffect(() => { useLayoutEffect(() => {
setChangeCount((prev) => prev + 1); setChangeCount((prev) => prev + 1);
}, [icon]); }, [icon]);
const handleAddEvent = useCallback<LeafletEventHandlerFn>( const handleAddEvent = useCallback<LeafletEventHandlerFn>(
(...args) => { (...args) => {
setMarkerRendered(true); setMarkerRendered(true);
if (providedEventHandlers?.add) providedEventHandlers.add(...args); if (providedEventHandlers?.add) providedEventHandlers.add(...args);
}, },
[providedEventHandlers?.add], [providedEventHandlers?.add],
); );
const handleRemoveEvent = useCallback<LeafletEventHandlerFn>( const handleRemoveEvent = useCallback<LeafletEventHandlerFn>(
(...args) => { (...args) => {
setMarkerRendered(false); setMarkerRendered(false);
if (providedEventHandlers?.remove) if (providedEventHandlers?.remove)
providedEventHandlers.remove(...args); providedEventHandlers.remove(...args);
}, },
[providedEventHandlers?.remove], [providedEventHandlers?.remove],
); );
const eventHandlers = useMemo<LeafletEventHandlerFnMap>( const eventHandlers = useMemo<LeafletEventHandlerFnMap>(
() => ({ () => ({
...providedEventHandlers, ...providedEventHandlers,
add: handleAddEvent, add: handleAddEvent,
remove: handleRemoveEvent, remove: handleRemoveEvent,
}), }),
[providedEventHandlers, handleAddEvent, handleRemoveEvent], [providedEventHandlers, handleAddEvent, handleRemoveEvent],
); );
let portalTarget: null | HTMLElement = null; let portalTarget: null | HTMLElement = null;
if (markerRendered) { if (markerRendered) {
portalTarget = document.getElementById(id); portalTarget = document.getElementById(id);
} }
useEffect(() => { useEffect(() => {
if (!portalTarget) return; if (!portalTarget) return;
if (disableClickPropagation) { if (disableClickPropagation) {
DomEvent.disableClickPropagation(portalTarget); DomEvent.disableClickPropagation(portalTarget);
} }
if (disableScrollPropagation) { if (disableScrollPropagation) {
DomEvent.disableScrollPropagation(portalTarget); DomEvent.disableScrollPropagation(portalTarget);
} }
}, [portalTarget, disableClickPropagation, disableScrollPropagation]); }, [portalTarget, disableClickPropagation, disableScrollPropagation]);
return ( return (
<> <>
<ReactLeafletMarker <ReactLeafletMarker
ref={ref} ref={ref}
{...otherProps} {...otherProps}
eventHandlers={eventHandlers} eventHandlers={eventHandlers}
icon={icon} icon={icon}
/> />
{markerRendered && portalTarget !== null && ( {markerRendered && portalTarget !== null && (
<> <>
<InPortal node={portalNode}>{providedIcon}</InPortal> <InPortal node={portalNode}>{providedIcon}</InPortal>
{createPortal(<OutPortal node={portalNode} />, portalTarget)} {createPortal(<OutPortal node={portalNode} />, portalTarget)}
</> </>
)} )}
</> </>
); );
}, },
); );
ComponentMarker.displayName = "ComponentMarker"; ComponentMarker.displayName = "ComponentMarker";
@ -342,59 +344,59 @@ ComponentMarker.displayName = "ComponentMarker";
* ``` * ```
**/ **/
export const Marker = forwardRef<LeafletMarker, MarkerProps>( export const Marker = forwardRef<LeafletMarker, MarkerProps>(
({ icon: Icon, componentIconOpts, ...otherProps }, ref) => { ({ icon: Icon, componentIconOpts, ...otherProps }, ref) => {
const validElement = isValidElement(Icon); const validElement = isValidElement(Icon);
const validComponent = isValidElementType(Icon); const validComponent = isValidElementType(Icon);
useEffect(() => { useEffect(() => {
if ( if (
!validElement && !validElement &&
!validComponent && !validComponent &&
componentIconOpts !== undefined && componentIconOpts !== undefined &&
componentIconOpts.unusedOptsWarning !== false componentIconOpts.unusedOptsWarning !== false
) { ) {
console.warn( console.warn(
`UNUSED_OPTIONS The 'componentIconOpts' prop was set but the 'icon' prop was not set to a React component or element. These options will be unused. `UNUSED_OPTIONS The 'componentIconOpts' prop was set but the 'icon' prop was not set to a React component or element. These options will be unused.
To disable this warning set 'componentIconOpts.unusedOptsWarning' to false.`, To disable this warning set 'componentIconOpts.unusedOptsWarning' to false.`,
); );
} }
}, [ }, [
componentIconOpts, componentIconOpts,
componentIconOpts?.unusedOptsWarning, componentIconOpts?.unusedOptsWarning,
validElement, validElement,
validComponent, validComponent,
]); ]);
if (validElement) { if (validElement) {
return ( return (
<ComponentMarker <ComponentMarker
ref={ref} ref={ref}
icon={Icon} icon={Icon}
componentIconOpts={componentIconOpts} componentIconOpts={componentIconOpts}
{...otherProps} {...otherProps}
/> />
); );
} }
if (validComponent) { if (validComponent) {
return ( return (
<ComponentMarker <ComponentMarker
ref={ref} ref={ref}
icon={<Icon />} icon={<Icon />}
componentIconOpts={componentIconOpts} componentIconOpts={componentIconOpts}
{...otherProps} {...otherProps}
/> />
); );
} }
return ( return (
<ReactLeafletMarker <ReactLeafletMarker
ref={ref} ref={ref}
icon={Icon as ReactLeafletMarkerProps["icon"]} icon={Icon as ReactLeafletMarkerProps["icon"]}
{...otherProps} {...otherProps}
/> />
); );
}, },
); );
Marker.displayName = "Marker"; Marker.displayName = "Marker";

View file

@ -1,99 +1,99 @@
"use client"; "use client";
import { cn } from "~/lib/utils";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
interface MapMarkerProps { interface MapMarkerProps {
label: ReactNode; label: ReactNode;
color?: "primary" | "red" | "blue" | "green" | "yellow" | "purple" | "gray"; color?: "primary" | "red" | "blue" | "green" | "yellow" | "purple" | "gray";
className?: string; className?: string;
size?: "sm" | "md" | "lg"; size?: "sm" | "md" | "lg";
} }
export default function MapMarker({ export default function MapMarker({
label, label,
color = "primary", color = "primary",
className, className,
size = "md", size = "md",
}: MapMarkerProps) { }: MapMarkerProps) {
const colorClasses = { const colorClasses = {
primary: { primary: {
rectangle: "bg-[#242428] text-white", rectangle: "bg-[#242428] text-white",
triangle: "border-t-[#242428]", triangle: "border-t-[#242428]",
}, },
red: { red: {
rectangle: "bg-red-500 text-white", rectangle: "bg-red-500 text-white",
triangle: "border-t-red-500", triangle: "border-t-red-500",
}, },
blue: { blue: {
rectangle: "bg-blue-500 text-white", rectangle: "bg-blue-500 text-white",
triangle: "border-t-blue-500", triangle: "border-t-blue-500",
}, },
green: { green: {
rectangle: "bg-green-500 text-white", rectangle: "bg-green-500 text-white",
triangle: "border-t-green-500", triangle: "border-t-green-500",
}, },
yellow: { yellow: {
rectangle: "bg-yellow-500 text-black", rectangle: "bg-yellow-500 text-black",
triangle: "border-t-yellow-500", triangle: "border-t-yellow-500",
}, },
purple: { purple: {
rectangle: "bg-purple-500 text-white", rectangle: "bg-purple-500 text-white",
triangle: "border-t-purple-500", triangle: "border-t-purple-500",
}, },
gray: { gray: {
rectangle: "bg-gray-500 text-white", rectangle: "bg-gray-500 text-white",
triangle: "border-t-gray-500", triangle: "border-t-gray-500",
}, },
}; };
const sizeClasses = { const sizeClasses = {
sm: { sm: {
container: "h-8", container: "h-8",
rectangle: "px-2 py-1 rounded-md", rectangle: "px-2 py-1 rounded-md",
triangle: "border-l-[8px] border-r-[8px] border-t-[8px]", triangle: "border-l-[8px] border-r-[8px] border-t-[8px]",
text: "text-xs", text: "text-xs",
}, },
md: { md: {
container: "h-12", container: "h-12",
rectangle: "px-3 py-2 rounded-lg", rectangle: "px-3 py-2 rounded-lg",
triangle: "border-l-[10px] border-r-[10px] border-t-[10px]", triangle: "border-l-[10px] border-r-[10px] border-t-[10px]",
text: "text-sm", text: "text-sm",
}, },
lg: { lg: {
container: "h-16", container: "h-16",
rectangle: "px-4 py-3 rounded-xl", rectangle: "px-4 py-3 rounded-xl",
triangle: "border-l-[12px] border-r-[12px] border-t-[12px]", triangle: "border-l-[12px] border-r-[12px] border-t-[12px]",
text: "text-base", text: "text-base",
}, },
}; };
return ( return (
<div <div
className={cn( className={cn(
"inline-flex flex-col items-center", "inline-flex flex-col items-center",
sizeClasses[size].container, sizeClasses[size].container,
className, className,
)} )}
> >
{/* Rectangle part */} {/* Rectangle part */}
<div <div
className={cn( className={cn(
"flex items-center justify-center font-medium text-primary-foreground shadow-md", "flex items-center justify-center font-medium text-primary-foreground shadow-md",
sizeClasses[size].rectangle, sizeClasses[size].rectangle,
colorClasses[color].rectangle, colorClasses[color].rectangle,
)} )}
> >
<span className={sizeClasses[size].text}>{label}</span> <span className={sizeClasses[size].text}>{label}</span>
</div> </div>
{/* Triangle part */} {/* Triangle part */}
<div <div
className={cn( className={cn(
"h-0 w-0 border-solid border-transparent", "h-0 w-0 border-solid border-transparent",
sizeClasses[size].triangle, sizeClasses[size].triangle,
colorClasses[color].triangle, colorClasses[color].triangle,
)} )}
/> />
</div> </div>
); );
} }

View file

@ -1,44 +1,44 @@
import { useState } from "react";
import { useRouter } from "next/router";
import { setCookie } from "cookies-next/client"; import { setCookie } from "cookies-next/client";
import { EngFlag, ItaFlag } from "~/components/svgs";
import LoadingButton from "~/components/custom_ui/loading-button";
import { add } from "date-fns"; import { add } from "date-fns";
import { useRouter } from "next/router";
import { useState } from "react";
import LoadingButton from "~/components/custom_ui/loading-button";
import { EngFlag, ItaFlag } from "~/components/svgs";
export const LangToggle = () => { export const LangToggle = () => {
const router = useRouter(); const router = useRouter();
const locale = router.locale || "it"; const locale = router.locale || "it";
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const handleLangChange = async (lang: string) => { const handleLangChange = async (lang: string) => {
if (lang === locale) return; if (lang === locale) return;
setBusy(true); setBusy(true);
setCookie("NEXT_LOCALE", lang, { setCookie("NEXT_LOCALE", lang, {
expires: add(new Date(), { expires: add(new Date(), {
days: 30, days: 30,
}), }),
}); });
await router.push(router.pathname, router.asPath, { locale: lang }); await router.push(router.pathname, router.asPath, { locale: lang });
setBusy(false); setBusy(false);
}; };
return ( return (
<LoadingButton <LoadingButton
aria-label="Change Language" aria-label="Change Language"
loading={busy} loading={busy}
variant="outline" variant="outline"
className="text-primary flex w-fit items-center gap-2" className="text-primary flex w-fit items-center gap-2"
onClick={() => handleLangChange(locale === "it" ? "en" : "it")} onClick={() => handleLangChange(locale === "it" ? "en" : "it")}
> >
{locale === "it" ? ( {locale === "it" ? (
<> <>
<ItaFlag className="size-5" /> <ItaFlag className="size-5" />
</> </>
) : ( ) : (
<> <>
<EngFlag className="size-5" /> <EngFlag className="size-5" />
</> </>
)} )}
</LoadingButton> </LoadingButton>
); );
}; };

View file

@ -1,200 +1,200 @@
"use client"; "use client";
import { LogIn, LogOut } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { IconMatrix, type IconType } from "~/components/IconComponents";
import { LoadingSpinner } from "~/components/spinner";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { UserAvatar } from "~/components/user_avatar";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useSession } from "~/providers/SessionProvider"; import { useSession } from "~/providers/SessionProvider";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { Button } from "~/components/ui/button";
import Link from "next/link";
import { LogIn, LogOut } from "lucide-react";
import { useTranslation } from "~/providers/I18nProvider";
import { cn } from "~/lib/utils";
import { IconMatrix, type IconType } from "~/components/IconComponents";
import { usePathname } from "next/navigation";
import { UserAvatar } from "~/components/user_avatar";
import { LoadingSpinner } from "~/components/spinner";
type LoginButtonProps = { type LoginButtonProps = {
open: boolean; open: boolean;
setOpen: (status: boolean) => void; setOpen: (status: boolean) => void;
setSidebar: (status: boolean) => void; setSidebar: (status: boolean) => void;
inSidebar?: boolean; inSidebar?: boolean;
}; };
export const UserHeaderSection = ({ export const UserHeaderSection = ({
open, open,
setOpen, setOpen,
setSidebar, setSidebar,
inSidebar, inSidebar,
}: LoginButtonProps) => { }: LoginButtonProps) => {
const router = useRouter(); const router = useRouter();
const session = useSession(); const session = useSession();
const { t } = useTranslation(); const { t } = useTranslation();
const path = usePathname(); const path = usePathname();
const utils = api.useUtils(); const utils = api.useUtils();
const { mutate: logout } = api.auth.logoutUser.useMutation({ const { mutate: logout } = api.auth.logoutUser.useMutation({
onSuccess: async () => { onSuccess: async () => {
if (path.startsWith("/area-riservata")) { if (path.startsWith("/area-riservata")) {
await router.push("/"); await router.push("/");
} else { } else {
router.reload(); router.reload();
} }
await utils.auth.getSession.invalidate(); await utils.auth.getSession.invalidate();
}, },
}); });
if (session.status === "pending") { if (session.status === "pending") {
return ( return (
<div className="flex items-center px-3 py-2"> <div className="flex items-center px-3 py-2">
<LoadingSpinner className="size-5" /> <LoadingSpinner className="size-5" />
</div> </div>
); );
} }
return ( return (
<div className="flex items-center"> <div className="flex items-center">
{!session || !session.user ? ( {!session || !session.user ? (
<Button <Button
aria-label="Login Button" aria-label="Login Button"
variant="ghost" variant="ghost"
onClick={async () => { onClick={async () => {
await router.push({ await router.push({
pathname: "/login", pathname: "/login",
query: { redirect: router.asPath }, query: { redirect: router.asPath },
}); });
setSidebar(false); setSidebar(false);
}} }}
className="flex h-[46px] w-full items-center justify-center gap-x-1 rounded-lg p-2" className="flex h-[46px] w-full items-center justify-center gap-x-1 rounded-lg p-2"
> >
{inSidebar && t.auth.login.titolo} {inSidebar && t.auth.login.titolo}
<LogIn className="size-5" /> <LogIn className="size-5" />
</Button> </Button>
) : ( ) : (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
aria-label="User Profile Button" aria-label="User Profile Button"
variant="ghost" variant="ghost"
className="flex h-[46px] w-full items-center justify-center gap-x-1 rounded-full p-2 font-medium" className="flex h-[46px] w-full items-center justify-center gap-x-1 rounded-full p-2 font-medium"
> >
<UserAvatar <UserAvatar
username={session.user.username} username={session.user.username}
userId={session.user.id} userId={session.user.id}
className="size-10" className="size-10"
/> />
{inSidebar ? ( {inSidebar ? (
<span>{session.user.username}</span> <span>{session.user.username}</span>
) : ( ) : (
<span className="navqry:block hidden"> <span className="navqry:block hidden">
{session.user.username} {session.user.username}
</span> </span>
)} )}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount> <DropdownMenuContent className="w-56" align="end" forceMount>
{session.user.isAdmin ? ( {session.user.isAdmin ? (
<> <>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium"> <p className="text-sm leading-none font-medium">
{session.user.username} {session.user.username}
</p> </p>
<p className="text-muted-foreground text-xs leading-none"> <p className="text-muted-foreground text-xs leading-none">
Amministratore Amministratore
</p> </p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Items items={t.nav.loginMenuAdmin} setOpen={setOpen} /> <Items items={t.nav.loginMenuAdmin} setOpen={setOpen} />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="flex cursor-pointer items-center gap-3 px-2 py-1.5" className="flex cursor-pointer items-center gap-3 px-2 py-1.5"
aria-label="Logout Button" aria-label="Logout Button"
onClick={() => { onClick={() => {
logout(); logout();
}} }}
> >
<LogOut className="size-6" /> <LogOut className="size-6" />
{t.auth.login.logout} {t.auth.login.logout}
</DropdownMenuItem> </DropdownMenuItem>
</> </>
) : ( ) : (
<> <>
<DropdownMenuLabel className="font-normal"> <DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1"> <div className="flex flex-col space-y-1">
<p className="text-sm leading-none font-medium"> <p className="text-sm leading-none font-medium">
{session.user.username} {session.user.username}
</p> </p>
<p className="text-muted-foreground text-xs leading-none"> <p className="text-muted-foreground text-xs leading-none">
{session.user.email} {session.user.email}
</p> </p>
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<Items items={t.nav.loginMenuUser} setOpen={setOpen} /> <Items items={t.nav.loginMenuUser} setOpen={setOpen} />
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
aria-label="Logout Button" aria-label="Logout Button"
className="flex cursor-pointer items-center gap-3 px-2 py-1.5" className="flex cursor-pointer items-center gap-3 px-2 py-1.5"
onClick={() => { onClick={() => {
logout(); logout();
}} }}
> >
<LogOut className="size-6" /> <LogOut className="size-6" />
{t.auth.login.logout} {t.auth.login.logout}
</DropdownMenuItem> </DropdownMenuItem>
</> </>
)} )}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
)} )}
</div> </div>
); );
}; };
const Items = ({ const Items = ({
items, items,
setOpen, setOpen,
}: { }: {
items: { items: {
title: string; title: string;
icon: { icon: IconType; className: string }; icon: { icon: IconType; className: string };
href: string; href: string;
}[]; }[];
setOpen: (status: boolean) => void; setOpen: (status: boolean) => void;
}) => { }) => {
return ( return (
<DropdownMenuGroup> <DropdownMenuGroup>
{items?.map((item, idx) => ( {items?.map((item, idx) => (
<DropdownMenuItem key={idx} className="p-0"> <DropdownMenuItem key={idx} className="p-0">
<Link <Link
aria-label={`Link to ${item.title}`} aria-label={`Link to ${item.title}`}
onClick={() => setOpen(false)} onClick={() => setOpen(false)}
href={item.href} href={item.href}
className="flex h-full w-full items-center gap-3 px-2 py-1.5" className="flex h-full w-full items-center gap-3 px-2 py-1.5"
> >
<IconMatrix <IconMatrix
type={item.icon.icon} type={item.icon.icon}
className={cn("size-6", item.icon.className)} className={cn("size-6", item.icon.className)}
/> />
{item.title} {item.title}
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
))} ))}
</DropdownMenuGroup> </DropdownMenuGroup>
); );
}; };

View file

@ -1,39 +1,40 @@
import Link from "next/link"; import Link from "next/link";
import { cn } from "~/lib/utils";
import { LogoSvg } from "~/components/svgs"; import { LogoSvg } from "~/components/svgs";
import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider"; import { useTranslation } from "~/providers/I18nProvider";
type MainNavProps = { type MainNavProps = {
pathname: string; pathname: string;
}; };
export function MainNav({ pathname }: MainNavProps) { export function MainNav({ pathname }: MainNavProps) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="mr-2 hidden gap-6 md:flex"> <div className="mr-2 hidden gap-6 md:flex">
<Link <Link
aria-label="mainNavLink" aria-label="mainNavLink"
href="/" href="/"
className="flex shrink-0 items-center font-medium md:mb-0" className="flex shrink-0 items-center font-medium md:mb-0"
> >
<LogoSvg className="[&>text]:fill-primary [&>text]:stroke-primary h-8 w-auto [&>g]:fill-red-500" /> <LogoSvg className="[&>text]:fill-primary [&>text]:stroke-primary h-8 w-auto [&>g]:fill-red-500" />
</Link> </Link>
<nav className="flex items-center gap-4 text-lg tracking-wide lg:gap-6"> <nav className="flex items-center gap-4 text-lg tracking-wide lg:gap-6">
{t.nav.mainNav.map((item) => ( {t.nav.mainNav.map((item) => (
<Link <Link
aria-label={item.title} aria-label={item.title}
key={item.href} key={item.href}
href={item.href} href={item.href}
className={cn( className={cn(
"hover:text-primary/80 transition-colors", "hover:text-primary/80 transition-colors",
pathname === item.href ? "text-primary" : "text-primary/70", pathname === item.href ? "text-primary" : "text-primary/70",
)} )}
> >
{item.title} {item.title}
</Link> </Link>
))} ))}
</nav> </nav>
</div> </div>
); );
} }

View file

@ -1,362 +1,361 @@
import type { ReactNode } from "react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import Link, { type LinkProps } from "next/link";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { AlignLeft, Home } from "lucide-react"; import { AlignLeft, Home } from "lucide-react";
import Link, { type LinkProps } from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useSession } from "~/providers/SessionProvider"; import type { ReactNode } from "react";
import { IconMatrix } from "~/components/IconComponents"; import { IconMatrix } from "~/components/IconComponents";
import { useTranslation } from "~/providers/I18nProvider"; import { LangToggle } from "~/components/navbar/lang-toggle";
import { Separator } from "~/components/ui/separator";
import { UserHeaderSection } from "~/components/navbar/login-button"; import { UserHeaderSection } from "~/components/navbar/login-button";
import { ASvg, LogoSvg } from "~/components/svgs"; import { ASvg, LogoSvg } from "~/components/svgs";
import { Button } from "~/components/ui/button";
import { Separator } from "~/components/ui/separator";
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "~/components/ui/sheet";
import { useMediaQuery } from "~/hooks/use-media-query"; import { useMediaQuery } from "~/hooks/use-media-query";
import { LangToggle } from "~/components/navbar/lang-toggle"; import { cn } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useSession } from "~/providers/SessionProvider";
type MobileNavProps = { type MobileNavProps = {
pathname: string; pathname: string;
open: boolean; open: boolean;
setOpen: (status: boolean) => void; setOpen: (status: boolean) => void;
userOpen: boolean; userOpen: boolean;
setUserOpen: (status: boolean) => void; setUserOpen: (status: boolean) => void;
}; };
export function MobileNav({ export function MobileNav({
pathname, pathname,
open, open,
setOpen, setOpen,
userOpen, userOpen,
setUserOpen, setUserOpen,
}: MobileNavProps) { }: MobileNavProps) {
const session = useSession(); const session = useSession();
const { t } = useTranslation(); const { t } = useTranslation();
const verySmallScreen = useMediaQuery("(max-width: 320px)"); const verySmallScreen = useMediaQuery("(max-width: 320px)");
return ( return (
<> <>
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
<SheetTrigger asChild> <SheetTrigger asChild>
<Button <Button
aria-label="Toggle Mobile Menu" aria-label="Toggle Mobile Menu"
variant="ghost" variant="ghost"
className="mr-2 size-8 p-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden [&_svg]:size-6" className="mr-2 size-8 p-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden [&_svg]:size-6"
> >
<AlignLeft /> <AlignLeft />
<span className="sr-only">Toggle Menu</span> <span className="sr-only">Toggle Menu</span>
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent side="left" className="z-500 p-0"> <SheetContent side="left" className="z-500 p-0">
<SheetHeader> <SheetHeader>
<SheetTitle className="sr-only">Mobile Menu</SheetTitle> <SheetTitle className="sr-only">Mobile Menu</SheetTitle>
<SheetDescription className="sr-only"> <SheetDescription className="sr-only">
This is the mobile menu This is the mobile menu
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
<div className="flex h-full flex-col space-y-2 overflow-y-auto px-3 pb-4"> <div className="flex h-full flex-col space-y-2 overflow-y-auto px-3 pb-4">
<MobileLink <MobileLink
href="/" href="/"
aria-label="mainNavLinkHome" aria-label="mainNavLinkHome"
className="ml-2 flex items-center gap-3" className="ml-2 flex items-center gap-3"
onOpenChange={setOpen} onOpenChange={setOpen}
> >
<Home className="size-6" />{" "} <Home className="size-6" />{" "}
<span className="text-xl font-medium">Home</span> <span className="text-xl font-medium">Home</span>
</MobileLink> </MobileLink>
<div className="flex h-full flex-col space-y-2 pt-4"> <div className="flex h-full flex-col space-y-2 pt-4">
{pathname.startsWith("/area-riservata") && ( {pathname.startsWith("/area-riservata") && (
<> <>
{session && session.user && ( {session?.user && (
<AreaRiservataLinks <AreaRiservataLinks
isAdmin={session.user.isAdmin} isAdmin={session.user.isAdmin}
pathname={pathname} pathname={pathname}
setOpen={setOpen} setOpen={setOpen}
/> />
)} )}
<Separator /> <Separator />
</> </>
)} )}
<div className="text-primary flex h-full grow flex-col gap-3"> <div className="text-primary flex h-full grow flex-col gap-3">
{t.nav.mainNav.map((item) => ( {t.nav.mainNav.map((item) => (
<MobileLink <MobileLink
key={item.href} key={item.href}
href={item.href} href={item.href}
onOpenChange={setOpen} onOpenChange={setOpen}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1 font-medium", "flex items-center gap-3 rounded-lg px-2 py-1 font-medium",
item.href === pathname && "bg-muted", item.href === pathname && "bg-muted",
)} )}
> >
<IconMatrix <IconMatrix
key={item.title} key={item.title}
type={item.icon} type={item.icon}
className="size-5" className="size-5"
/>{" "} />{" "}
{item.title} {item.title}
</MobileLink> </MobileLink>
))} ))}
<Separator /> <Separator />
</div> </div>
<div className="flex h-fit flex-col gap-4 pb-2"> <div className="flex h-fit flex-col gap-4 pb-2">
<UserHeaderSection <UserHeaderSection
open={userOpen} open={userOpen}
setOpen={setUserOpen} setOpen={setUserOpen}
setSidebar={setOpen} setSidebar={setOpen}
inSidebar inSidebar
/> />
<div className="flex items-center justify-center gap-6"> <div className="flex items-center justify-center gap-6">
<LangToggle /> <LangToggle />
{/* <ThemeSwitch /> */} {/* <ThemeSwitch /> */}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
<Link <Link
href="/" href="/"
className="ml-3 flex shrink-0 items-center md:hidden" className="ml-3 flex shrink-0 items-center md:hidden"
aria-label="mainNavLink" aria-label="mainNavLink"
> >
{verySmallScreen ? ( {verySmallScreen ? (
<ASvg className="h-8 w-auto" /> <ASvg className="h-8 w-auto" />
) : ( ) : (
<LogoSvg className="xs:h-8 [&>text]:fill-primary [&>text]:stroke-primary h-6 w-auto text-neutral-700 sm:h-10 [&>g]:fill-red-500 dark:[&>g]:fill-neutral-100 dark:[&>text]:fill-neutral-100 dark:[&>text]:stroke-neutral-100" /> <LogoSvg className="xs:h-8 [&>text]:fill-primary [&>text]:stroke-primary h-6 w-auto text-neutral-700 sm:h-10 [&>g]:fill-red-500 dark:[&>g]:fill-neutral-100 dark:[&>text]:fill-neutral-100 dark:[&>text]:stroke-neutral-100" />
)} )}
</Link> </Link>
</> </>
); );
} }
interface MobileLinkProps extends LinkProps { interface MobileLinkProps extends LinkProps {
onOpenChange?: (open: boolean) => void; onOpenChange?: (open: boolean) => void;
children: ReactNode; children: ReactNode;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
} }
function MobileLink({ function MobileLink({
href, href,
onOpenChange, onOpenChange,
className, className,
children, children,
disabled, disabled,
...props ...props
}: MobileLinkProps) { }: MobileLinkProps) {
const router = useRouter(); const router = useRouter();
return ( return (
<Link <Link
aria-label="mobileLink" aria-label="mobileLink"
href={href} href={href}
onClick={(e) => { onClick={(e) => {
if (disabled) { if (disabled) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
onOpenChange?.(false); onOpenChange?.(false);
} else { } else {
router.push( router.push(
// eslint-disable-next-line @typescript-eslint/no-base-to-string href.toString(),
href.toString(), );
); onOpenChange?.(false);
onOpenChange?.(false); }
} }}
}} className={cn(className)}
className={cn(className)} {...props}
{...props} >
> {children}
{children} </Link>
</Link> );
);
} }
type AreaRiservataLinksProps = { type AreaRiservataLinksProps = {
isAdmin: boolean; isAdmin: boolean;
setOpen?: (status: boolean) => void; setOpen?: (status: boolean) => void;
pathname: string; pathname: string;
}; };
const AreaRiservataLinks = ({ const AreaRiservataLinks = ({
isAdmin, isAdmin,
setOpen, setOpen,
pathname, pathname,
}: AreaRiservataLinksProps) => { }: AreaRiservataLinksProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="text-primary flex flex-col space-y-4 text-base"> <div className="text-primary flex flex-col space-y-4 text-base">
{isAdmin {isAdmin
? t.nav.areaRiservataAdminNav.map((item) => { ? t.nav.areaRiservataAdminNav.map((item) => {
if (item.items !== undefined) { if (item.items !== undefined) {
return ( return (
<div key={item.title}> <div key={item.title}>
<div <div
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1 font-medium", "flex items-center gap-3 rounded-lg px-2 py-1 font-medium",
item.href === pathname && "bg-muted", item.href === pathname && "bg-muted",
)} )}
> >
<IconMatrix type={item.icon} className="size-5" /> <IconMatrix type={item.icon} className="size-5" />
{item.title} {item.title}
</div> </div>
<div> <div>
<ul> <ul>
{item.items.map((item) => { {item.items.map((item) => {
return ( return (
<MobileLink <MobileLink
key={item.title} key={item.title}
href={item.href} href={item.href}
onOpenChange={setOpen} onOpenChange={setOpen}
className={cn( className={cn(
`hover:text-accent-foreground mx-4 block w-full border-l px-6 py-2 duration-150 hover:border-neutral-800`, `hover:text-accent-foreground mx-4 block w-full border-l px-6 py-2 duration-150 hover:border-neutral-800`,
item.href === pathname && item.href === pathname &&
"border-neutral-900 text-neutral-900", "border-neutral-900 text-neutral-900",
)} )}
> >
{item.title} {item.title}
</MobileLink> </MobileLink>
); );
})} })}
</ul> </ul>
</div> </div>
</div> </div>
); );
} }
return ( return (
<MobileLink <MobileLink
key={item.title} key={item.title}
href={item.href} href={item.href}
onOpenChange={setOpen} onOpenChange={setOpen}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1 font-medium", "flex items-center gap-3 rounded-lg px-2 py-1 font-medium",
item.href === pathname && "bg-muted", item.href === pathname && "bg-muted",
)} )}
> >
<IconMatrix type={item.icon} className="size-5" /> <IconMatrix type={item.icon} className="size-5" />
{item.title} {item.title}
</MobileLink> </MobileLink>
); );
}) })
: t.nav.areaRiservataUserNav.map((item) => ( : t.nav.areaRiservataUserNav.map((item) => (
<MobileLink <MobileLink
key={item.title} key={item.title}
href={item.href} href={item.href}
onOpenChange={setOpen} onOpenChange={setOpen}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1 font-medium", "flex items-center gap-3 rounded-lg px-2 py-1 font-medium",
item.href === pathname && "bg-muted", item.href === pathname && "bg-muted",
)} )}
> >
<IconMatrix type={item.icon} className="size-5" /> <IconMatrix type={item.icon} className="size-5" />
{item.title} {item.title}
</MobileLink> </MobileLink>
))} ))}
</div> </div>
); );
}; };
export const ARMinimizableLinks = ({ export const ARMinimizableLinks = ({
isAdmin, isAdmin,
setOpen, setOpen,
pathname, pathname,
minimized, minimized,
}: { }: {
isAdmin: boolean; isAdmin: boolean;
setOpen?: (status: boolean) => void; setOpen?: (status: boolean) => void;
pathname: string; pathname: string;
minimized: boolean; minimized: boolean;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div <div
className={cn( className={cn(
"text-primary flex w-full flex-col space-y-4 text-base", "text-primary flex w-full flex-col space-y-4 text-base",
minimized && "justify-center", minimized && "justify-center",
)} )}
> >
{isAdmin {isAdmin
? t.nav.areaRiservataAdminNav.map((item) => { ? t.nav.areaRiservataAdminNav.map((item) => {
if (item.items !== undefined) { if (item.items !== undefined) {
return ( return (
<div key={item.title}> <div key={item.title}>
<div <div
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1 font-medium", "flex items-center gap-3 rounded-lg px-2 py-1 font-medium",
item.href === pathname && "bg-muted", item.href === pathname && "bg-muted",
minimized && "justify-center", minimized && "justify-center",
)} )}
> >
<IconMatrix type={item.icon} className="size-5" /> <IconMatrix type={item.icon} className="size-5" />
{!minimized && item.title} {!minimized && item.title}
</div> </div>
<div> <div>
<ul className="truncate"> <ul className="truncate">
{item.items.map((item) => { {item.items.map((item) => {
return ( return (
<MobileLink <MobileLink
key={item.title} key={item.title}
href={item.href} href={item.href}
onOpenChange={setOpen} onOpenChange={setOpen}
className={cn( className={cn(
`hover:text-accent-foreground mx-4 block w-full border-l px-6 py-2 duration-150 hover:border-neutral-800`, `hover:text-accent-foreground mx-4 block w-full border-l px-6 py-2 duration-150 hover:border-neutral-800`,
item.href === pathname && item.href === pathname &&
"border-neutral-900 text-neutral-900", "border-neutral-900 text-neutral-900",
minimized && "hidden", minimized && "hidden",
)} )}
disabled={minimized} disabled={minimized}
> >
{item.title} {item.title}
</MobileLink> </MobileLink>
); );
})} })}
</ul> </ul>
</div> </div>
</div> </div>
); );
} }
return ( return (
<MobileLink <MobileLink
key={item.title} key={item.title}
href={item.href} href={item.href}
onOpenChange={setOpen} onOpenChange={setOpen}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1 font-medium", "flex items-center gap-3 rounded-lg px-2 py-1 font-medium",
item.href === pathname && "bg-muted", item.href === pathname && "bg-muted",
minimized && "justify-center", minimized && "justify-center",
)} )}
disabled={minimized} disabled={minimized}
> >
<IconMatrix type={item.icon} className="size-5" /> <IconMatrix type={item.icon} className="size-5" />
{!minimized && item.title} {!minimized && item.title}
</MobileLink> </MobileLink>
); );
}) })
: t.nav.areaRiservataUserNav.map((item) => ( : t.nav.areaRiservataUserNav.map((item) => (
<MobileLink <MobileLink
key={item.title} key={item.title}
href={item.href} href={item.href}
onOpenChange={setOpen} onOpenChange={setOpen}
className={cn( className={cn(
"flex items-center gap-3 rounded-lg px-2 py-1 font-medium", "flex items-center gap-3 rounded-lg px-2 py-1 font-medium",
item.href === pathname && "bg-muted", item.href === pathname && "bg-muted",
minimized && "justify-center", minimized && "justify-center",
)} )}
disabled={minimized} disabled={minimized}
> >
<IconMatrix type={item.icon} className="size-5" /> <IconMatrix type={item.icon} className="size-5" />
{!minimized && item.title} {!minimized && item.title}
</MobileLink> </MobileLink>
))} ))}
</div> </div>
); );
}; };

View file

@ -1,50 +1,49 @@
"use client"; "use client";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "~/components/ui/dialog";
import { CodiceBox } from "~/components/codiceRicerca";
import { useRouter } from "next/router"; import { useRouter } from "next/router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { CodiceBox } from "~/components/codiceRicerca";
import { Button } from "~/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
export function RicercaCommand() { export function RicercaCommand() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const router = useRouter(); const router = useRouter();
useEffect(() => { useEffect(() => {
router.events.on("routeChangeStart", () => { router.events.on("routeChangeStart", () => {
setOpen(false); setOpen(false);
}); });
}, []); }, []);
return ( return (
<> <>
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button variant="ghost" className="p-2" aria-label="ricercaBtn"> <Button variant="ghost" className="p-2" aria-label="ricercaBtn">
<Search className="size-5" /> <Search className="size-5" />
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent className="border-none bg-transparent sm:max-w-xl [&_[data-slot=dialog-close]]:hidden"> <DialogContent className="border-none bg-transparent sm:max-w-xl [&_[data-slot=dialog-close]]:hidden">
<DialogHeader className="sr-only"> <DialogHeader className="sr-only">
<DialogTitle>Ricerca</DialogTitle> <DialogTitle>Ricerca</DialogTitle>
<DialogDescription>Effettua una ricerca</DialogDescription> <DialogDescription>Effettua una ricerca</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="relative mx-auto flex w-full gap-2"> <div className="relative mx-auto flex w-full gap-2">
<CodiceBox <CodiceBox
key={"codiceBox"} key={"codiceBox"}
className="outline-foreground focus:ring-foreground rounded-none rounded-t-md outline placeholder:text-center focus:border-none focus:outline-hidden" className="outline-foreground focus:ring-foreground rounded-none rounded-t-md outline placeholder:text-center focus:border-none focus:outline-hidden"
optionBoxClassName="rounded-none rounded-b-md mt-0 border-b-0 border-x-0" optionBoxClassName="rounded-none rounded-b-md mt-0 border-b-0 border-x-0"
/> />
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>
); );
} }

View file

@ -1,25 +1,25 @@
import { Button } from "~/components/ui/button";
import { Settings } from "lucide-react"; import { Settings } from "lucide-react";
import { LangToggle } from "~/components/navbar/lang-toggle"; import { LangToggle } from "~/components/navbar/lang-toggle";
import { Button } from "~/components/ui/button";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
export const SettingsPopover = () => { export const SettingsPopover = () => {
return ( return (
<Popover> <Popover>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button variant="ghost" className="p-2" aria-label="Settings"> <Button variant="ghost" className="p-2" aria-label="Settings">
<Settings className="size-5" /> <Settings className="size-5" />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="flex w-fit justify-center gap-4 p-4"> <PopoverContent className="flex w-fit justify-center gap-4 p-4">
<LangToggle /> <LangToggle />
{/* <ThemeSwitch /> */} {/* <ThemeSwitch /> */}
</PopoverContent> </PopoverContent>
</Popover> </Popover>
); );
}; };

View file

@ -1,51 +1,51 @@
"use client"; "use client";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { MainNav } from "~/components/navbar/main-nav";
import { MobileNav } from "~/components/navbar/mobile-nav";
import { useState } from "react"; import { useState } from "react";
import { UserHeaderSection } from "~/components/navbar/login-button"; import { UserHeaderSection } from "~/components/navbar/login-button";
import { SettingsPopover } from "~/components/navbar/settings-modal"; import { MainNav } from "~/components/navbar/main-nav";
import { MobileNav } from "~/components/navbar/mobile-nav";
import { RicercaCommand } from "~/components/navbar/ricerca_command"; import { RicercaCommand } from "~/components/navbar/ricerca_command";
import { SettingsPopover } from "~/components/navbar/settings-modal";
export function SiteHeader() { export function SiteHeader() {
const pathname = usePathname(); const pathname = usePathname();
const [openSidebar, setOpenSidebar] = useState(false); const [openSidebar, setOpenSidebar] = useState(false);
const [openUserMenu, setOpenUserMenu] = useState(false); const [openUserMenu, setOpenUserMenu] = useState(false);
const [openUserMenuSidebar, setOpenUserMenuSidebar] = useState(false); const [openUserMenuSidebar, setOpenUserMenuSidebar] = useState(false);
const handleOpenMenu = (status: boolean) => { const handleOpenMenu = (status: boolean) => {
setOpenUserMenu(status); setOpenUserMenu(status);
}; };
const handleOpenSidebar = (status: boolean) => { const handleOpenSidebar = (status: boolean) => {
setOpenSidebar(status); setOpenSidebar(status);
}; };
return ( return (
<header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur"> <header className="border-border/40 bg-background/95 supports-[backdrop-filter]:bg-background/60 sticky top-0 z-50 w-full border-b backdrop-blur">
<div className="mx-auto flex h-14 w-full items-center pr-3 pl-4 md:pr-4 md:pl-8"> <div className="mx-auto flex h-14 w-full items-center pr-3 pl-4 md:pr-4 md:pl-8">
<MainNav pathname={pathname} /> <MainNav pathname={pathname} />
<MobileNav <MobileNav
pathname={pathname} pathname={pathname}
open={openSidebar} open={openSidebar}
setOpen={setOpenSidebar} setOpen={setOpenSidebar}
userOpen={openUserMenuSidebar} userOpen={openUserMenuSidebar}
setUserOpen={setOpenUserMenuSidebar} setUserOpen={setOpenUserMenuSidebar}
/> />
<div className="float-end flex flex-1 items-center justify-end gap-0.5"> <div className="float-end flex flex-1 items-center justify-end gap-0.5">
<RicercaCommand /> <RicercaCommand />
<div className="hidden md:block"> <div className="hidden md:block">
<SettingsPopover /> <SettingsPopover />
</div> </div>
<UserHeaderSection <UserHeaderSection
key={pathname} key={pathname}
open={openUserMenu} open={openUserMenu}
setOpen={handleOpenMenu} setOpen={handleOpenMenu}
setSidebar={handleOpenSidebar} setSidebar={handleOpenSidebar}
/> />
</div> </div>
</div> </div>
</header> </header>
); );
} }

View file

@ -1,43 +1,43 @@
import { motion, type Transition } from "framer-motion";
import { Moon, Sun } from "lucide-react"; import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes"; import { useTheme } from "next-themes";
import { motion, type Transition } from "framer-motion";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
export const ThemeSwitch = () => { export const ThemeSwitch = () => {
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const spring: Transition = { const spring: Transition = {
type: "spring", type: "spring",
stiffness: 700, stiffness: 700,
damping: 30, damping: 30,
}; };
return ( return (
<button <button
aria-label="Toggle Theme" aria-label="Toggle Theme"
type="button" type="button"
onClick={() => setTheme(theme === "light" ? "dark" : "light")} onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className={cn( className={cn(
`flex h-10 w-20 rounded-full bg-neutral-200 p-[5px] shadow-inner hover:cursor-pointer dark:bg-neutral-700`, `flex h-10 w-20 rounded-full bg-neutral-200 p-[5px] shadow-inner hover:cursor-pointer dark:bg-neutral-700`,
theme == "light" && "place-content-end", theme === "light" && "place-content-end",
)} )}
> >
<motion.div <motion.div
className={cn( className={cn(
"flex size-8 items-center justify-center rounded-full bg-black/90", "flex size-8 items-center justify-center rounded-full bg-black/90",
//theme == "light" && "bg-sky-500", //theme == "light" && "bg-sky-500",
)} )}
layout layout
transition={spring} transition={spring}
> >
<motion.div whileTap={{ rotate: 360 }}> <motion.div whileTap={{ rotate: 360 }}>
{theme == "light" ? ( {theme === "light" ? (
<Sun className="size-6 stroke-yellow-300" /> <Sun className="size-6 stroke-yellow-300" />
) : ( ) : (
<Moon className="size-6 stroke-slate-200" /> <Moon className="size-6 stroke-slate-200" />
)} )}
</motion.div> </motion.div>
</motion.div> </motion.div>
</button> </button>
); );
}; };

View file

@ -1,53 +1,54 @@
import { CheckCircle, AlertCircle, Loader2 } from "lucide-react"; import { AlertCircle, CheckCircle, Loader2 } from "lucide-react";
type PaymentStatus = "success" | "processing" | "error"; type PaymentStatus = "success" | "processing" | "error";
interface PaymentStatusProps { interface PaymentStatusProps {
status: PaymentStatus; status: PaymentStatus;
title: string; title: string;
message?: string; message?: string;
} }
export default function PaymentStatus({ export default function PaymentStatus({
status, status,
message, message,
title, title,
}: PaymentStatusProps) { }: PaymentStatusProps) {
const statusConfig = { const statusConfig = {
success: { success: {
icon: CheckCircle, icon: CheckCircle,
color: "text-green-500", color: "text-green-500",
bgColor: "bg-green-50", bgColor: "bg-green-50",
borderColor: "border-green-200", borderColor: "border-green-200",
}, },
processing: { processing: {
icon: Loader2, icon: Loader2,
color: "text-blue-500", color: "text-blue-500",
bgColor: "bg-blue-50", bgColor: "bg-blue-50",
borderColor: "border-blue-200", borderColor: "border-blue-200",
}, },
error: { error: {
icon: AlertCircle, icon: AlertCircle,
color: "text-red-500", color: "text-red-500",
bgColor: "bg-red-50", bgColor: "bg-red-50",
borderColor: "border-red-200", borderColor: "border-red-200",
}, },
}; };
const { icon: Icon, color, bgColor, borderColor } = statusConfig[status]; const { icon: Icon, color, bgColor, borderColor } = statusConfig[status];
return ( return (
<div <div
className={`mb-4 flex items-center rounded-lg border p-4 ${bgColor} ${borderColor}`} className={`mb-4 flex items-center rounded-lg border p-4 ${bgColor} ${borderColor}`}
> >
<div <div
className={`size-8 shrink-0 ${color} ${status === "processing" ? "animate-spin" : ""}`} className={`size-8 shrink-0 ${color} ${status === "processing" ? "animate-spin" : ""}`}
> >
<Icon className="size-8" /> <Icon className="size-8" />
</div> </div>
<div className="ml-3"> <div className="ml-3">
<h3 className={`text-sm font-medium ${color}`}>{title}</h3> <h3 className={`text-sm font-medium ${color}`}>{title}</h3>
{message && <div className="mt-2 text-sm text-gray-700">{message}</div>} {message && <div className="mt-2 text-sm text-gray-700">{message}</div>}
</div> </div>
</div> </div>
); );
} }

View file

@ -7,227 +7,228 @@ import flags from "react-phone-number-input/flags";
import EN from "react-phone-number-input/locale/en"; import EN from "react-phone-number-input/locale/en";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Command, Command,
CommandEmpty, CommandEmpty,
CommandGroup, CommandGroup,
CommandInput, CommandInput,
CommandItem, CommandItem,
} from "~/components/ui/command"; } from "~/components/ui/command";
import { Input } from "~/components/ui/input"; import { Input } from "~/components/ui/input";
import { import {
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
} from "~/components/ui/popover"; } from "~/components/ui/popover";
import { ScrollArea } from "~/components/ui/scroll-area"; import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
type PhoneInputProps = Omit< type PhoneInputProps = Omit<
React.ComponentProps<"input">, React.ComponentProps<"input">,
"onChange" | "value" | "ref" "onChange" | "value" | "ref"
> & > &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & { Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void; onChange?: (value: RPNInput.Value) => void;
}; };
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = React.memo( const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = React.memo(
React.forwardRef< React.forwardRef<
React.ComponentRef<typeof RPNInput.default>, React.ComponentRef<typeof RPNInput.default>,
PhoneInputProps PhoneInputProps
>(({ className, onChange, ...props }, ref) => { >(({ className, onChange, ...props }, ref) => {
return ( return (
<RPNInput.default <RPNInput.default
ref={ref} ref={ref}
className={cn( className={cn(
"[&_input]:ph-input-fix flex h-auto w-full flex-1 [&_*]:!h-auto", "[&_input]:ph-input-fix flex h-auto w-full flex-1 [&_*]:!h-auto",
className, className,
)} )}
flagComponent={FlagComponent} flagComponent={FlagComponent}
countrySelectComponent={CountrySelect} countrySelectComponent={CountrySelect}
inputComponent={InputComponent} inputComponent={InputComponent}
smartCaret={false} smartCaret={false}
defaultCountry="IT" defaultCountry="IT"
international international
labels={EN} labels={EN}
focusInputOnCountrySelection focusInputOnCountrySelection
/** /**
* Handles the onChange event. * Handles the onChange event.
* *
* react-phone-number-input might trigger the onChange event as undefined * react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this, * when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string. * the value is coerced to an empty string.
* *
* @param {E164Number | undefined} value - The entered value * @param {E164Number | undefined} value - The entered value
*/ */
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))} onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props} {...props}
/> />
); );
}), }),
); );
PhoneInput.displayName = "PhoneInput"; PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef< const InputComponent = React.forwardRef<
HTMLInputElement, HTMLInputElement,
React.ComponentProps<"input"> React.ComponentProps<"input">
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<Input <Input
className={cn("w-full flex-1 rounded-s-none rounded-e-md", className)} className={cn("w-full flex-1 rounded-s-none rounded-e-md", className)}
{...props} {...props}
ref={ref} ref={ref}
/> />
)); ));
InputComponent.displayName = "InputComponent"; InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined }; type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = { type CountrySelectProps = {
disabled?: boolean; disabled?: boolean;
value: RPNInput.Country; value: RPNInput.Country;
options: CountryEntry[]; options: CountryEntry[];
onChange: (country: RPNInput.Country) => void; onChange: (country: RPNInput.Country) => void;
}; };
const CountrySelect = React.memo( const CountrySelect = React.memo(
({ ({
disabled, disabled,
value: selectedCountry, value: selectedCountry,
options: countryList, options: countryList,
onChange, onChange,
}: CountrySelectProps) => { }: CountrySelectProps) => {
const [filteredCountries, setFilteredCountries] = React.useState< const [filteredCountries, setFilteredCountries] = React.useState<
CountryEntry[] CountryEntry[]
>(countryList || []); >(countryList || []);
const [open, setOpen] = React.useState(false); const [open, setOpen] = React.useState(false);
const [parentNode, setParentNode] = React.useState<HTMLDivElement | null>( const [parentNode, setParentNode] = React.useState<HTMLDivElement | null>(
null, null,
); );
const refCallback = React.useCallback((node: HTMLDivElement) => { const refCallback = React.useCallback((node: HTMLDivElement) => {
if (node) { if (node) {
setParentNode(node); setParentNode(node);
} }
}, []); }, []);
const virtualizer = useVirtualizer({ const virtualizer = useVirtualizer({
count: filteredCountries.length, count: filteredCountries.length,
getScrollElement: () => parentNode, getScrollElement: () => parentNode,
estimateSize: () => 35, estimateSize: () => 35,
overscan: 5, overscan: 5,
}); });
const handleSearch = (search: string) => { const handleSearch = (search: string) => {
if (!search.trim()) { if (!search.trim()) {
setFilteredCountries(countryList); setFilteredCountries(countryList);
} else { } else {
setFilteredCountries( setFilteredCountries(
countryList.filter((country) => countryList.filter((country) =>
country.label.toLowerCase().includes(search.toLowerCase()), country.label.toLowerCase().includes(search.toLowerCase()),
), ),
); );
} }
}; };
return ( return (
<Popover open={open} onOpenChange={setOpen}> <Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild> <PopoverTrigger asChild>
<Button <Button
role="combobox" role="combobox"
variant="outline" variant="outline"
aria-expanded={open} aria-expanded={open}
className="flex gap-1 rounded-md rounded-e-none border-r-0 px-3 py-2 focus:z-10" className="flex gap-1 rounded-md rounded-e-none border-r-0 px-3 py-2 focus:z-10"
> >
<FlagComponent <FlagComponent
country={selectedCountry} country={selectedCountry}
countryName={ countryName={
countryList.find((c) => c.value === selectedCountry)?.label || countryList.find((c) => c.value === selectedCountry)?.label ||
"" ""
} }
/> />
<ChevronsUpDown <ChevronsUpDown
className={cn( className={cn(
"-mr-2 size-4 opacity-50", "-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100", disabled ? "hidden" : "opacity-100",
)} )}
/> />
</Button> </Button>
</PopoverTrigger> </PopoverTrigger>
<PopoverContent className="w-[300px] p-0"> <PopoverContent className="w-[300px] p-0">
<Command shouldFilter={false}> <Command shouldFilter={false}>
<CommandInput <CommandInput
placeholder="Search country..." placeholder="Search country..."
onValueChange={handleSearch} onValueChange={handleSearch}
className="border-none ring-0 focus:ring-0 focus-visible:ring-0" className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/> />
<ScrollArea className="h-[400px] overflow-auto"> <ScrollArea className="h-[400px] overflow-auto">
<CommandEmpty>No countries found</CommandEmpty> <CommandEmpty>No countries found</CommandEmpty>
<CommandGroup ref={refCallback}> <CommandGroup ref={refCallback}>
<div <div
style={{ style={{
height: `${virtualizer.getTotalSize()}px`, height: `${virtualizer.getTotalSize()}px`,
width: "100%", width: "100%",
position: "relative", position: "relative",
}} }}
> >
{virtualizer {virtualizer
.getVirtualItems() .getVirtualItems()
.map(({ index, size, start }) => { .map(({ index, size, start }) => {
const country = filteredCountries[index]; const country = filteredCountries[index];
if (!country?.value) return null; if (!country?.value) return null;
return ( return (
<CommandItem <CommandItem
key={country.value} key={country.value}
style={{ style={{
position: "absolute", position: "absolute",
top: 0, top: 0,
left: 0, left: 0,
width: "100%", width: "100%",
height: `${size}px`, height: `${size}px`,
transform: `translateY(${start}px)`, transform: `translateY(${start}px)`,
}} }}
onSelect={() => onChange(country.value!)} // biome-ignore lint/style/noNonNullAssertion: <exists>
value={country.value} onSelect={() => onChange(country.value!)}
> value={country.value}
<FlagComponent >
country={country.value} <FlagComponent
countryName={country.label} country={country.value}
/> countryName={country.label}
<span className="flex-1 text-sm"> />
{country.label} <span className="flex-1 text-sm">
</span> {country.label}
<span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode( </span>
country.value, <span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(
)}`}</span> country.value,
<CheckIcon )}`}</span>
className={`ml-auto size-4 ${ <CheckIcon
country.value === selectedCountry className={`ml-auto size-4 ${
? "opacity-100" country.value === selectedCountry
: "opacity-0" ? "opacity-100"
}`} : "opacity-0"
/> }`}
</CommandItem> />
); </CommandItem>
})} );
</div> })}
</CommandGroup> </div>
</ScrollArea> </CommandGroup>
</Command> </ScrollArea>
</PopoverContent> </Command>
</Popover> </PopoverContent>
); </Popover>
}, );
},
); );
CountrySelect.displayName = "CountrySelect"; CountrySelect.displayName = "CountrySelect";
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => { const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country]; const Flag = flags[country];
return ( return (
<span className="flex size-6 items-center justify-center overflow-hidden rounded-sm"> <span className="flex size-6 items-center justify-center overflow-hidden rounded-sm">
{Flag ? <Flag title={countryName} /> : <Globe className="size-4" />} {Flag ? <Flag title={countryName} /> : <Globe className="size-4" />}
</span> </span>
); );
}; };
export { PhoneInput }; export { PhoneInput };

View file

@ -1,384 +1,384 @@
import { useTranslation } from "~/providers/I18nProvider"; import { ExternalLink } from "lucide-react";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AnimatedNumber } from "~/components/custom_ui/animated-number";
import { LoadingPage } from "~/components/loading";
import { Button } from "~/components/ui/button"; import { Button } from "~/components/ui/button";
import { import {
Card, Card,
CardHeader, CardContent,
CardTitle, CardDescription,
CardDescription, CardHeader,
CardContent, CardTitle,
} from "~/components/ui/card"; } from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { cn } from "~/lib/utils"; import { cn } from "~/lib/utils";
import { AnimatedNumber } from "~/components/custom_ui/animated-number"; import { useTranslation } from "~/providers/I18nProvider";
import { ExternalLink } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "~/components/ui/tabs";
import Link from "next/link";
import { api } from "~/utils/api";
import { LoadingPage } from "~/components/loading";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum"; import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { useEffect, useState } from "react"; import { api } from "~/utils/api";
export const PricingChoice = () => { export const PricingChoice = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: prezziario, isLoading } = const { data: prezziario, isLoading } =
api.prezziario.getPrezziPerTipologiaAll.useQuery(); api.prezziario.getPrezziPerTipologiaAll.useQuery();
if (isLoading) return <LoadingPage />; if (isLoading) return <LoadingPage />;
if (!prezziario) return null; if (!prezziario) return null;
const pricingBreve = prezziario.transitorio.saldi.map((s) => ({ const pricingBreve = prezziario.transitorio.saldi.map((s) => ({
downPayment: prezziario.transitorio.acconto.prezzo_cent / 100, downPayment: prezziario.transitorio.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100, secondPayment: s.prezzo_cent / 100,
})); }));
const pricingStabile = prezziario.stabile.saldi.map((s) => ({ const pricingStabile = prezziario.stabile.saldi.map((s) => ({
downPayment: prezziario.stabile.acconto.prezzo_cent / 100, downPayment: prezziario.stabile.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100, secondPayment: s.prezzo_cent / 100,
})); }));
return ( return (
<div className="text-primary flex flex-col items-center justify-center gap-4"> <div className="text-primary flex flex-col items-center justify-center gap-4">
<h2 className="text-3xl font-semibold">Scegli il tuo servizio</h2> <h2 className="text-3xl font-semibold">Scegli il tuo servizio</h2>
<Tabs defaultValue="transitorio" className="mb-2 w-full max-w-xl"> <Tabs defaultValue="transitorio" className="mb-2 w-full max-w-xl">
<TabsList className="flex h-auto w-full items-center justify-between gap-1 p-1"> <TabsList className="flex h-auto w-full items-center justify-between gap-1 p-1">
<TabsTrigger <TabsTrigger
value="transitorio" value="transitorio"
className="bg-transitorio [&[data-state=active]]:bg-transitorio w-full cursor-pointer px-2 text-xl sm:px-3" className="bg-transitorio [&[data-state=active]]:bg-transitorio w-full cursor-pointer px-2 text-xl sm:px-3"
> >
Transitorio Transitorio
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="stabile" value="stabile"
className="bg-stabile [&[data-state=active]]:bg-stabile w-full cursor-pointer px-2 text-xl sm:px-3" className="bg-stabile [&[data-state=active]]:bg-stabile w-full cursor-pointer px-2 text-xl sm:px-3"
> >
Stabile Stabile
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="transitorio" className="w-full"> <TabsContent value="transitorio" className="w-full">
<PricingComponent <PricingComponent
title={t.prezzi.titolo_transitori} title={t.prezzi.titolo_transitori}
cta={t.pricing_cmp.cta_breve} cta={t.pricing_cmp.cta_breve}
pricingData={pricingBreve} pricingData={pricingBreve}
opzioni={[ opzioni={[
t.pricing_cmp.breve_option1, t.pricing_cmp.breve_option1,
t.pricing_cmp.breve_option2, t.pricing_cmp.breve_option2,
t.pricing_cmp.breve_option3, t.pricing_cmp.breve_option3,
t.pricing_cmp.breve_option4, t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5, t.pricing_cmp.breve_option5,
]} ]}
className="border-transitorio bg-transitorio" className="border-transitorio bg-transitorio"
tipo={TipologiaPosizioneEnum.Transitorio} tipo={TipologiaPosizioneEnum.Transitorio}
/> />
</TabsContent> </TabsContent>
<TabsContent value="stabile"> <TabsContent value="stabile">
<PricingComponent <PricingComponent
title={t.prezzi.titolo_stabile} title={t.prezzi.titolo_stabile}
cta={t.pricing_cmp.cta_stabile} cta={t.pricing_cmp.cta_stabile}
pricingData={pricingStabile} pricingData={pricingStabile}
opzioni={[ opzioni={[
t.pricing_cmp.stabile_option1, t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2, t.pricing_cmp.stabile_option2,
]} ]}
className="border-stabile bg-stabile" className="border-stabile bg-stabile"
tipo={TipologiaPosizioneEnum.Stabile} tipo={TipologiaPosizioneEnum.Stabile}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<Link aria-label="Link to pricing page" href="/prezzi"> <Link aria-label="Link to pricing page" href="/prezzi">
<Button className="flex h-12 flex-row gap-2 px-6 py-4 text-2xl hover:underline"> <Button className="flex h-12 flex-row gap-2 px-6 py-4 text-2xl hover:underline">
<span>Scopri di più sui prezzi</span> <span>Scopri di più sui prezzi</span>
<ExternalLink className="size-5" /> <ExternalLink className="size-5" />
</Button> </Button>
</Link> </Link>
</div> </div>
); );
}; };
type PricingData = { downPayment: number; secondPayment: number }[]; type PricingData = { downPayment: number; secondPayment: number }[];
export const PricingComponent = ({ export const PricingComponent = ({
pricingData, pricingData,
opzioni, opzioni,
cta, cta,
statico = false, statico = false,
className, className,
title, title,
tipo, tipo,
}: { }: {
pricingData: PricingData; pricingData: PricingData;
opzioni: string[]; opzioni: string[];
title: string; title: string;
cta: string; cta: string;
statico?: boolean; statico?: boolean;
className?: string; className?: string;
tipo: TipologiaPosizioneEnum; tipo: TipologiaPosizioneEnum;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [Value, setValue] = useState<number>(0); const [Value, setValue] = useState<number>(0);
const p = pricingData[Value]; const p = pricingData[Value];
const downPayment = p?.downPayment || 0; const downPayment = p?.downPayment || 0;
const secondPayment = p?.secondPayment || 0; const secondPayment = p?.secondPayment || 0;
const customFormat = (num: number) => `${num}`; const customFormat = (num: number) => `${num}`;
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null); const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
if (statico) return; if (statico) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setValue((prevValue) => (prevValue + 1) % pricingData.length); setValue((prevValue) => (prevValue + 1) % pricingData.length);
}, 2500); }, 2500);
setIntervalId(interval); setIntervalId(interval);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
const ClearCarousel = () => { const ClearCarousel = () => {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
} }
}; };
return ( return (
<Card className={cn("w-full max-w-xl gap-4 py-4", className)}> <Card className={cn("w-full max-w-xl gap-4 py-4", className)}>
<CardHeader className="py-2"> <CardHeader className="py-2">
<CardTitle className="pt-1 text-center text-3xl font-semibold"> <CardTitle className="pt-1 text-center text-3xl font-semibold">
{title} {title}
</CardTitle> </CardTitle>
<CardDescription className="sr-only"> <CardDescription className="sr-only">
Prezzo del servizio Prezzo del servizio
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="xs:px-6 flex w-full flex-col items-center justify-center gap-4 p-0 px-2 py-0 sm:gap-6 sm:py-4"> <CardContent className="xs:px-6 flex w-full flex-col items-center justify-center gap-4 p-0 px-2 py-0 sm:gap-6 sm:py-4">
<div <div
className="xs:gap-x-4 xs:gap-y-1 flex w-full items-center justify-center gap-0" className="xs:gap-x-4 xs:gap-y-1 flex w-full items-center justify-center gap-0"
data-role="wrapper" data-role="wrapper"
> >
<div className="xs:w-1/3 flex w-2/5 flex-col items-center gap-2"> <div className="xs:w-1/3 flex w-2/5 flex-col items-center gap-2">
<h2 className="text-2xl font-semibold" data-role="title"> <h2 className="text-2xl font-semibold" data-role="title">
{t.acconto} {t.acconto}
</h2> </h2>
<p <p
className="xs:text-3xl text-2xl font-semibold sm:text-6xl" className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount" data-role="amount"
> >
{downPayment} {downPayment}
</p> </p>
</div> </div>
<div className="xs:w-1/3 flex w-2/5 flex-col items-center gap-2 text-center"> <div className="xs:w-1/3 flex w-2/5 flex-col items-center gap-2 text-center">
<h2 className="text-2xl font-semibold" data-role="title"> <h2 className="text-2xl font-semibold" data-role="title">
{t.saldo} {t.saldo}
</h2> </h2>
<p <p
className="xs:text-3xl text-2xl font-semibold sm:text-6xl" className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount" data-role="amount"
> >
<AnimatedNumber <AnimatedNumber
value={secondPayment} value={secondPayment}
format={customFormat} format={customFormat}
precision={secondPayment % 1 === 0 ? 0 : 2} precision={secondPayment % 1 === 0 ? 0 : 2}
/> />
</p> </p>
</div> </div>
<div className="xs:w-1/3 flex w-2/5 flex-col items-center gap-2 text-center"> <div className="xs:w-1/3 flex w-2/5 flex-col items-center gap-2 text-center">
<h2 className="text-2xl font-semibold" data-role="title"> <h2 className="text-2xl font-semibold" data-role="title">
Contratto Contratto
</h2> </h2>
{tipo == TipologiaPosizioneEnum.Transitorio ? ( {tipo === TipologiaPosizioneEnum.Transitorio ? (
<div className="flex flex-col justify-center leading-none"> <div className="flex flex-col justify-center leading-none">
<span <span
className="xs:text-3xl text-2xl font-semibold sm:text-6xl" className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount" data-role="amount"
> >
0 0
</span> </span>
</div> </div>
) : ( ) : (
<Link <Link
href={"/prezzi#contratti"} href={"/prezzi#contratti"}
className="flex flex-col justify-center leading-none sm:h-16" className="flex flex-col justify-center leading-none sm:h-16"
> >
<Button className="flex items-center gap-2"> <Button className="flex items-center gap-2">
Più info <ExternalLink className="size-4" /> Più info <ExternalLink className="size-4" />
</Button> </Button>
</Link> </Link>
)} )}
</div> </div>
</div> </div>
<div className="flex w-full flex-wrap items-center justify-center gap-2"> <div className="flex w-full flex-wrap items-center justify-center gap-2">
{opzioni.map((opzione, idx) => ( {opzioni.map((opzione, idx) => (
<Button <Button
key={idx} key={idx}
size="default" size="default"
className={cn( className={cn(
"xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3", "xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3",
idx === Value ? "animate-in fade-in-0" : "", idx === Value ? "animate-in fade-in-0" : "",
)} )}
variant={idx === Value ? "default" : "outline"} variant={idx === Value ? "default" : "outline"}
onClick={() => { onClick={() => {
ClearCarousel(); ClearCarousel();
setValue(idx); setValue(idx);
}} }}
> >
{opzione} {opzione}
</Button> </Button>
))} ))}
</div> </div>
<h2 className="text-center text-xl font-semibold text-white">{cta}</h2> <h2 className="text-center text-xl font-semibold text-white">{cta}</h2>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
export const PricingComponentConsulenza = ({ export const PricingComponentConsulenza = ({
pricingData, pricingData,
opzioni, opzioni,
cta, cta,
statico = false, statico = false,
className, className,
title, title,
}: { }: {
pricingData: number[]; pricingData: number[];
opzioni: string[]; opzioni: string[];
title: string; title: string;
cta: string; cta: string;
statico?: boolean; statico?: boolean;
className?: string; className?: string;
}) => { }) => {
const [Value, setValue] = useState<number>(0); const [Value, setValue] = useState<number>(0);
const p = pricingData[Value]; const p = pricingData[Value];
const customFormat = (num: number) => `${num}`; const customFormat = (num: number) => `${num}`;
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null); const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
if (statico) return; if (statico) return;
const interval = setInterval(() => { const interval = setInterval(() => {
setValue((prevValue) => (prevValue + 1) % pricingData.length); setValue((prevValue) => (prevValue + 1) % pricingData.length);
}, 2500); }, 2500);
setIntervalId(interval); setIntervalId(interval);
return () => clearInterval(interval); return () => clearInterval(interval);
}, []); }, []);
const ClearCarousel = () => { const ClearCarousel = () => {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
} }
}; };
return ( return (
<Card className={cn("w-full max-w-xl", className)}> <Card className={cn("w-full max-w-xl", className)}>
<CardHeader className="p-3 px-5"> <CardHeader className="p-3 px-5">
<CardTitle className="pt-1 text-center text-3xl font-semibold"> <CardTitle className="pt-1 text-center text-3xl font-semibold">
{title} {title}
</CardTitle> </CardTitle>
<CardDescription className="sr-only"> <CardDescription className="sr-only">
Prezzo del servizio Prezzo del servizio
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="xs:px-6 flex flex-col items-center justify-center gap-8 p-0 px-2 pt-4 pb-4"> <CardContent className="xs:px-6 flex flex-col items-center justify-center gap-8 p-0 px-2 pt-4 pb-4">
<div className="xs:gap-4 flex w-full items-center justify-center gap-0"> <div className="xs:gap-4 flex w-full items-center justify-center gap-0">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<p className="xs:text-6xl text-3xl font-semibold"> <p className="xs:text-6xl text-3xl font-semibold">
<AnimatedNumber value={p || 0} format={customFormat} /> <AnimatedNumber value={p || 0} format={customFormat} />
</p> </p>
</div> </div>
</div> </div>
<div className="flex w-full flex-wrap items-center justify-center gap-2"> <div className="flex w-full flex-wrap items-center justify-center gap-2">
{opzioni.map((opzione, idx) => ( {opzioni.map((opzione, idx) => (
<Button <Button
key={idx} key={idx}
size="default" size="default"
className={cn( className={cn(
"xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3", "xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3",
idx === Value ? "animate-in fade-in-0" : "", idx === Value ? "animate-in fade-in-0" : "",
)} )}
variant={idx === Value ? "default" : "outline"} variant={idx === Value ? "default" : "outline"}
onClick={() => { onClick={() => {
ClearCarousel(); ClearCarousel();
setValue(idx); setValue(idx);
}} }}
> >
{opzione} {opzione}
</Button> </Button>
))} ))}
</div> </div>
<h2 className="text-center text-xl font-semibold text-white">{cta}</h2> <h2 className="text-center text-xl font-semibold text-white">{cta}</h2>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
export const PrezziShow = () => { export const PrezziShow = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { data: prezziario, isLoading } = const { data: prezziario, isLoading } =
api.prezziario.getPrezziPerTipologiaAll.useQuery(); api.prezziario.getPrezziPerTipologiaAll.useQuery();
if (isLoading) return <LoadingPage />; if (isLoading) return <LoadingPage />;
if (!prezziario) return null; if (!prezziario) return null;
const pricingBreve = prezziario.transitorio.saldi.map((s) => ({ const pricingBreve = prezziario.transitorio.saldi.map((s) => ({
downPayment: prezziario.transitorio.acconto.prezzo_cent / 100, downPayment: prezziario.transitorio.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100, secondPayment: s.prezzo_cent / 100,
})); }));
const pricingStabile = prezziario.stabile.saldi.map((s) => ({ const pricingStabile = prezziario.stabile.saldi.map((s) => ({
downPayment: prezziario.stabile.acconto.prezzo_cent / 100, downPayment: prezziario.stabile.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100, secondPayment: s.prezzo_cent / 100,
})); }));
return ( return (
<div className="text-primary mx-auto flex w-full flex-col items-center justify-center gap-8 py-6 sm:max-w-5xl sm:flex-row"> <div className="text-primary mx-auto flex w-full flex-col items-center justify-center gap-8 py-6 sm:max-w-5xl sm:flex-row">
<div className="flex w-full flex-col gap-2 text-start sm:w-2/5 sm:text-left"> <div className="flex w-full flex-col gap-2 text-start sm:w-2/5 sm:text-left">
<h1 className="text-primary text-3xl font-bold"> <h1 className="text-primary text-3xl font-bold">
Quanto costa il servizio? Quanto costa il servizio?
</h1> </h1>
<p className="text-muted-foreground text-lg"> <p className="text-muted-foreground text-lg">
Il nostro servizio è pensato per essere accessibile e conveniente, con Il nostro servizio è pensato per essere accessibile e conveniente, con
tariffe chiare e senza sorprese. Basate sul tipo di affitto che tariffe chiare e senza sorprese. Basate sul tipo di affitto che
desideri, le nostre tariffe sono suddivise in due categorie desideri, le nostre tariffe sono suddivise in due categorie
principali: Affitto Transitorio e Affitto Stabile. principali: Affitto Transitorio e Affitto Stabile.
</p> </p>
</div> </div>
<Tabs defaultValue="transitorio" className="w-full sm:w-3/5"> <Tabs defaultValue="transitorio" className="w-full sm:w-3/5">
<TabsList className="flex h-auto w-full items-center justify-between"> <TabsList className="flex h-auto w-full items-center justify-between">
<TabsTrigger value="transitorio" className="w-full text-xl"> <TabsTrigger value="transitorio" className="w-full text-xl">
Affitto Transitorio Affitto Transitorio
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="stabile" className="w-full text-xl"> <TabsTrigger value="stabile" className="w-full text-xl">
Affitto Stabile Affitto Stabile
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
<TabsContent value="transitorio"> <TabsContent value="transitorio">
<PricingComponent <PricingComponent
title={t.prezzi.titolo_transitori} title={t.prezzi.titolo_transitori}
cta={t.pricing_cmp.cta_breve} cta={t.pricing_cmp.cta_breve}
pricingData={pricingBreve} pricingData={pricingBreve}
opzioni={[ opzioni={[
t.pricing_cmp.breve_option1, t.pricing_cmp.breve_option1,
t.pricing_cmp.breve_option2, t.pricing_cmp.breve_option2,
t.pricing_cmp.breve_option3, t.pricing_cmp.breve_option3,
t.pricing_cmp.breve_option4, t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5, t.pricing_cmp.breve_option5,
]} ]}
tipo={TipologiaPosizioneEnum.Transitorio} tipo={TipologiaPosizioneEnum.Transitorio}
className="border-transitorio bg-transitorio" className="border-transitorio bg-transitorio"
/> />
</TabsContent> </TabsContent>
<TabsContent value="stabile"> <TabsContent value="stabile">
<PricingComponent <PricingComponent
title={t.prezzi.titolo_stabile} title={t.prezzi.titolo_stabile}
cta={t.pricing_cmp.cta_stabile} cta={t.pricing_cmp.cta_stabile}
pricingData={pricingStabile} pricingData={pricingStabile}
opzioni={[ opzioni={[
t.pricing_cmp.stabile_option1, t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2, t.pricing_cmp.stabile_option2,
]} ]}
className="border-stabile bg-stabile" className="border-stabile bg-stabile"
tipo={TipologiaPosizioneEnum.Stabile} tipo={TipologiaPosizioneEnum.Stabile}
/> />
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
); );
}; };

View file

@ -6,57 +6,57 @@ import { useEffect, useState } from "react";
import { Progress } from "~/components/ui/progress"; import { Progress } from "~/components/ui/progress";
export const ProgressRedirect = ({ href }: { href: string }) => { export const ProgressRedirect = ({ href }: { href: string }) => {
const [progress, setProgress] = useState(0); const [progress, setProgress] = useState(0);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null); const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const router = useRouter(); const router = useRouter();
const handleProgress = () => { const handleProgress = () => {
setProgress((prev) => { setProgress((prev) => {
if (prev >= 100) { if (prev >= 100) {
if (intervalId) { if (intervalId) {
clearInterval(intervalId); clearInterval(intervalId);
} }
return 100; return 100;
} }
return prev + 20; // Increment progress by 20% every second return prev + 20; // Increment progress by 20% every second
}); });
}; };
useEffect(() => { useEffect(() => {
const id = setInterval(handleProgress, 1000); // Update progress every second const id = setInterval(handleProgress, 1000); // Update progress every second
setIntervalId(id); setIntervalId(id);
return () => { return () => {
if (id) { if (id) {
clearInterval(id); clearInterval(id);
} }
}; };
}, []); }, []);
useEffect(() => { useEffect(() => {
async function redirect() { async function redirect() {
await router.push(href); await router.push(href);
} }
if (progress == 100) { if (progress === 100) {
redirect().catch((error) => { redirect().catch((error) => {
console.error("Error during redirect:", error); console.error("Error during redirect:", error);
}); });
} }
}, [progress]); }, [progress]);
return ( return (
<div className="flex flex-col items-center justify-center gap-4"> <div className="flex flex-col items-center justify-center gap-4">
<Progress value={progress} className="h-3" /> <Progress value={progress} className="h-3" />
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Stai venendo reindirizzato... oppure Stai venendo reindirizzato... oppure
</p> </p>
<Link <Link
href={href} href={href}
className="font-semibold underline underline-offset-1" className="font-semibold underline underline-offset-1"
> >
Clicca qui Clicca qui
</Link> </Link>
</div> </div>
</div> </div>
); );
}; };

File diff suppressed because it is too large Load diff

View file

@ -1,282 +1,281 @@
import { useServizioAnnuncio } from "~/providers/ServizioProvider";
import { Interactions } from "~/components/servizio/interactions";
import type { Annunci } from "~/schemas/public/Annunci";
import { useTranslation } from "~/providers/I18nProvider";
import { cn, formatCurrency } from "~/lib/utils";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import { ImageFlbk } from "~/components/ImageWithFallback";
import { Button } from "~/components/ui/button";
import { ExternalLink, UnfoldVertical } from "lucide-react"; import { ExternalLink, UnfoldVertical } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { handleConsegna } from "~/lib/annuncio_details";
import { import {
Credenza, Credenza,
CredenzaBody, CredenzaBody,
CredenzaClose, CredenzaClose,
CredenzaContent, CredenzaContent,
CredenzaDescription, CredenzaDescription,
CredenzaFooter, CredenzaFooter,
CredenzaHeader, CredenzaHeader,
CredenzaTitle, CredenzaTitle,
CredenzaTrigger, CredenzaTrigger,
} from "~/components/custom_ui/credenza"; } from "~/components/custom_ui/credenza";
import { import { ImageFlbk } from "~/components/ImageWithFallback";
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import { AnnuncioActions } from "~/components/servizio/annuncio_actions"; import { AnnuncioActions } from "~/components/servizio/annuncio_actions";
import { Interactions } from "~/components/servizio/interactions";
import { Button } from "~/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import { handleConsegna } from "~/lib/annuncio_details";
import { cn, formatCurrency } from "~/lib/utils";
import { useTranslation } from "~/providers/I18nProvider";
import { useServizioAnnuncio } from "~/providers/ServizioProvider";
import type { Annunci } from "~/schemas/public/Annunci";
export const AnnuncioCard = ({ className }: { className?: string }) => { export const AnnuncioCard = ({ className }: { className?: string }) => {
const { data } = useServizioAnnuncio(); const { data } = useServizioAnnuncio();
return ( return (
<BasicAnnuncioCard <BasicAnnuncioCard
data={data} data={data}
className={className} className={className}
actions={<AnnuncioActions />} actions={<AnnuncioActions />}
interactions={<Interactions />} interactions={<Interactions />}
/> />
); );
}; };
export const BasicAnnuncioCard = ({ export const BasicAnnuncioCard = ({
actions, actions,
interactions, interactions,
className, className,
data, data,
}: { }: {
actions?: React.ReactNode; actions?: React.ReactNode;
interactions?: React.ReactNode; interactions?: React.ReactNode;
className?: string; className?: string;
data: Pick< data: Pick<
Annunci, Annunci,
| "id" | "id"
| "codice" | "codice"
| "prezzo" | "prezzo"
| "desc_en" | "desc_en"
| "desc_it" | "desc_it"
| "url_immagini" | "url_immagini"
| "titolo_en" | "titolo_en"
| "titolo_it" | "titolo_it"
| "tipo" | "tipo"
| "consegna" | "consegna"
| "stato" | "stato"
| "web" | "web"
>; >;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<Card className={cn("w-full border-white py-5", className)}> <Card className={cn("w-full border-white py-5", className)}>
<CardHeader className="flex flex-row flex-wrap items-center justify-between space-y-0 pb-0"> <CardHeader className="flex flex-row flex-wrap items-center justify-between space-y-0 pb-0">
<CardTitle className="text-2xl">Codice: {data.codice}</CardTitle> <CardTitle className="text-2xl">Codice: {data.codice}</CardTitle>
{actions} {actions}
</CardHeader> </CardHeader>
<CardContent className="px-4"> <CardContent className="px-4">
<div className="flex w-full flex-col gap-4 md:flex-row md:gap-10"> <div className="flex w-full flex-col gap-4 md:flex-row md:gap-10">
<ImageFlbk <ImageFlbk
src={ src={
(data.url_immagini && (data.url_immagini &&
`/go-api/images/get/${data.url_immagini[0]}`) || `/go-api/images/get/${data.url_immagini[0]}`) ||
"/fallback-image.png" "/fallback-image.png"
} }
priority priority
width={1920} width={1920}
height={1080} height={1080}
className="h-auto w-full rounded-md object-contain sm:max-w-xs" className="h-auto w-full rounded-md object-contain sm:max-w-xs"
alt={data.codice} alt={data.codice}
/> />
<div className="flex w-full flex-col gap-5"> <div className="flex w-full flex-col gap-5">
{interactions} {interactions}
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-3">
<TipoBadge tipo={data.tipo} /> <TipoBadge tipo={data.tipo} />
<span> <span>
{handleConsegna({ {handleConsegna({
consegna: data.consegna, consegna: data.consegna,
consegna_da: t.card.consegna_da, consegna_da: t.card.consegna_da,
mesi: t.preferenze.mesi, mesi: t.preferenze.mesi,
subito: t.card.consegna_subito, subito: t.card.consegna_subito,
aggiornamento: t.card.in_aggiornamento, aggiornamento: t.card.in_aggiornamento,
})} })}
</span> </span>
<span>{formatCurrency(data.prezzo / 1e2)}</span> <span>{formatCurrency(data.prezzo / 1e2)}</span>
</div> </div>
<div className="flex w-full flex-wrap items-center gap-5"> <div className="flex w-full flex-wrap items-center gap-5">
<span className="text-wrap">{data.titolo_it || data.codice}</span> <span className="text-wrap">{data.titolo_it || data.codice}</span>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<AnnuncioDettaglio data={data} /> <AnnuncioDettaglio data={data} />
{data.stato !== "Sospeso" && data.web && ( {data.stato !== "Sospeso" && data.web && (
<Link <Link
href={`/annuncio/${data.codice}`} href={`/annuncio/${data.codice}`}
aria-label="Pagina Annuncio" aria-label="Pagina Annuncio"
> >
<Button <Button
variant="outline" variant="outline"
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ExternalLink className="size-6" /> <ExternalLink className="size-6" />
</Button> </Button>
</Link> </Link>
)} )}
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
}; };
const TipoBadge = ({ tipo }: { tipo: Annunci["tipo"] }) => { const TipoBadge = ({ tipo }: { tipo: Annunci["tipo"] }) => {
return ( return (
<div <div
className={cn( className={cn(
"flex w-fit items-center justify-center rounded-md px-3", "flex w-fit items-center justify-center rounded-md px-3",
tipo === "Transitorio" && "bg-orange-400", tipo === "Transitorio" && "bg-orange-400",
tipo === "Stabile" && "bg-red-400", tipo === "Stabile" && "bg-red-400",
tipo === "Cessione" && "bg-blue-400", tipo === "Cessione" && "bg-blue-400",
tipo === "Vendita" && "bg-green-400", tipo === "Vendita" && "bg-green-400",
)} )}
> >
<div className="font-semibold text-white">{tipo}</div> <div className="font-semibold text-white">{tipo}</div>
</div> </div>
); );
}; };
const AnnuncioDettaglio = ({ const AnnuncioDettaglio = ({
data, data,
}: { }: {
data: Pick< data: Pick<
Annunci, Annunci,
| "id" | "id"
| "codice" | "codice"
| "prezzo" | "prezzo"
| "desc_en" | "desc_en"
| "desc_it" | "desc_it"
| "url_immagini" | "url_immagini"
| "titolo_en" | "titolo_en"
| "titolo_it" | "titolo_it"
| "tipo" | "tipo"
| "consegna" | "consegna"
| "stato" | "stato"
| "web" | "web"
>; >;
}) => { }) => {
const { locale, t } = useTranslation(); const { locale, t } = useTranslation();
function replaceWithBr(text: string) { function replaceWithBr(text: string) {
return text.replace(/\n/g, "<br />"); return text.replace(/\n/g, "<br />");
} }
return ( return (
<Credenza> <Credenza>
<CredenzaTrigger asChild> <CredenzaTrigger asChild>
<Button <Button
variant="outline" variant="outline"
className="flex items-center gap-2" className="flex items-center gap-2"
aria-label="Dettagli Annuncio" aria-label="Dettagli Annuncio"
> >
<UnfoldVertical className="size-6" /> Dettagli annuncio <UnfoldVertical className="size-6" /> Dettagli annuncio
</Button> </Button>
</CredenzaTrigger> </CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] md:max-w-6xl"> <CredenzaContent className="max-h-[90vh] md:max-w-6xl">
<CredenzaHeader> <CredenzaHeader>
<CredenzaTitle className="text-xl"> <CredenzaTitle className="text-xl">
Annuncio {data.codice} Annuncio {data.codice}
</CredenzaTitle> </CredenzaTitle>
<CredenzaDescription className="sr-only"> <CredenzaDescription className="sr-only">
Annuncio {data.codice} Annuncio {data.codice}
</CredenzaDescription> </CredenzaDescription>
</CredenzaHeader> </CredenzaHeader>
<CredenzaBody className="scrollbar-default max-h-[80vh] overflow-y-auto pb-5"> <CredenzaBody className="scrollbar-default max-h-[80vh] overflow-y-auto pb-5">
<div className="flex flex-col gap-2 px-2"> <div className="flex flex-col gap-2 px-2">
<Carousel opts={{ loop: true }}> <Carousel opts={{ loop: true }}>
<CarouselContent> <CarouselContent>
{data.url_immagini && {data?.url_immagini?.map((img, idx) => (
data.url_immagini.map((img, idx) => ( <CarouselItem key={img}>
<CarouselItem key={img}> <ImageFlbk
<ImageFlbk alt={`${data.codice} - ${idx}`}
alt={`${data.codice} - ${idx}`} width={400}
width={400} height={200}
height={200} priority={idx === 0}
priority={idx === 0} src={`/go-api/images/get/${img}`}
src={`/go-api/images/get/${img}`} className={"h-80 w-full object-contain sm:h-[35rem]"}
className={"h-80 w-full object-contain sm:h-[35rem]"} />
/> </CarouselItem>
</CarouselItem> ))}
))} </CarouselContent>
</CarouselContent> <div
<div onKeyDown={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()} role="button"
role="button" tabIndex={0}
tabIndex={0} onClick={(e) => e.preventDefault()}
onClick={(e) => e.preventDefault()} className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0"
className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0" >
> <CarouselPrevious
<CarouselPrevious className="left-2 cursor-pointer"
className="left-2 cursor-pointer" disabled={
disabled={ !data.url_immagini || data.url_immagini.length === 1
!data.url_immagini || data.url_immagini.length === 1 }
} />
/> <CarouselNext
<CarouselNext className="right-2 cursor-pointer"
className="right-2 cursor-pointer" disabled={
disabled={ !data.url_immagini || data.url_immagini.length === 1
!data.url_immagini || data.url_immagini.length === 1 }
} />
/> </div>
</div> </Carousel>
</Carousel> <div>
<div> <TipoBadge tipo={data.tipo} />
<TipoBadge tipo={data.tipo} />
<div className="mt-1 space-y-2"> <div className="mt-1 space-y-2">
<div className="flex flex-row justify-around"> <div className="flex flex-row justify-around">
<div className="text-center"> <div className="text-center">
<dt className="sr-only">{t.card.codice}</dt> <dt className="sr-only">{t.card.codice}</dt>
<dd className="text-lg font-semibold"> <dd className="text-lg font-semibold">
Cod: {data.codice} Cod: {data.codice}
</dd> </dd>
</div> </div>
<div className="text-center"> <div className="text-center">
<dt className="sr-only">{t.card.prezzo}</dt> <dt className="sr-only">{t.card.prezzo}</dt>
<dd className="text-lg font-semibold"> <dd className="text-lg font-semibold">
{formatCurrency(data.prezzo / 1e2)} {formatCurrency(data.prezzo / 1e2)}
</dd> </dd>
</div> </div>
</div> </div>
<div className="mb-3"> <div className="mb-3">
<dt className="sr-only">{t.card.titolo}</dt> <dt className="sr-only">{t.card.titolo}</dt>
<dd className="text-base font-semibold md:text-lg"> <dd className="text-base font-semibold md:text-lg">
{locale === "it" ? data.titolo_it : data.titolo_en} {locale === "it" ? data.titolo_it : data.titolo_en}
</dd> </dd>
</div> </div>
<div className="mb-3"> <div className="mb-3">
<dd <dd
className="text-base" className="text-base"
dangerouslySetInnerHTML={{ dangerouslySetInnerHTML={{
__html: replaceWithBr( __html: replaceWithBr(
(locale === "it" ? data.desc_it : data.desc_it) || "", (locale === "it" ? data.desc_it : data.desc_it) || "",
), ),
}} }}
/> />
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</CredenzaBody> </CredenzaBody>
<CredenzaFooter> <CredenzaFooter>
<CredenzaClose asChild> <CredenzaClose asChild>
<Button aria-label="Chiudi Credenza">Chiudi</Button> <Button aria-label="Chiudi Credenza">Chiudi</Button>
</CredenzaClose> </CredenzaClose>
</CredenzaFooter> </CredenzaFooter>
</CredenzaContent> </CredenzaContent>
</Credenza> </Credenza>
); );
}; };

Some files were not shown because too many files have changed in this diff Show more