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
headers
.kanelrc.js
eslint.config.mjs
prettier.config.cjs
kyselyRules

View file

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

View file

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

View file

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

View file

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

View file

@ -2,51 +2,51 @@ import { Button, Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type ContattoAnnuncio_NewMail = {
mailType: "contattoAnnuncioEmail";
props: ContattoAnnuncioEmailProps;
mailType: "contattoAnnuncioEmail";
props: ContattoAnnuncioEmailProps;
};
type ContattoAnnuncioEmailProps = {
nome: string;
codice: string;
email: string;
telefono: string;
messaggio: string;
nome: string;
codice: string;
email: string;
telefono: string;
messaggio: string;
};
const ContattoAnnuncioEmail = ({
nome,
codice,
email,
telefono,
messaggio,
nome,
codice,
email,
telefono,
messaggio,
}: ContattoAnnuncioEmailProps) => {
return (
<Base preview="Contatto Email">
<>
<Heading className="text-center text-3xl leading-8">
Richiesta di contatto
</Heading>
return (
<Base preview="Contatto Email">
<>
<Heading className="text-center text-3xl leading-8">
Richiesta di contatto
</Heading>
<Section className="px-5 text-left text-base md:px-12">
<Row>
<Text>Data: {new Date().toLocaleString()}</Text>
<Text>Nome: {nome}</Text>
<Text>Annuncio: {codice}</Text>
<Text>Telefono: {telefono}</Text>
<Text>Email: {email}</Text>
<Text>Messaggio: {messaggio}</Text>
</Row>
</Section>
<Section className="mb-5">
<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"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/annuncio/${codice}`}
>
Vai all&apos;annuncio
</Button>
</Section>
</>
</Base>
);
<Section className="px-5 text-left text-base md:px-12">
<Row>
<Text>Data: {new Date().toLocaleString()}</Text>
<Text>Nome: {nome}</Text>
<Text>Annuncio: {codice}</Text>
<Text>Telefono: {telefono}</Text>
<Text>Email: {email}</Text>
<Text>Messaggio: {messaggio}</Text>
</Row>
</Section>
<Section className="mb-5">
<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"
href={`${process.env.NEXT_PUBLIC_BASE_URL}/annuncio/${codice}`}
>
Vai all&apos;annuncio
</Button>
</Section>
</>
</Base>
);
};
export default ContattoAnnuncioEmail;

View file

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

View file

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

View file

@ -1,42 +1,42 @@
import { Button, Heading, Row, Section, Text } from "@react-email/components";
import Base from "./base";
export type PagamentoConferma_NewMail = {
mailType: "pagamentoConferma";
props: PagamentoConfermaProps;
mailType: "pagamentoConferma";
props: PagamentoConfermaProps;
};
type PagamentoConfermaProps = {
receiptHref: string;
receiptHref: string;
};
const PagamentoConferma = ({ receiptHref }: PagamentoConfermaProps) => {
return (
<Base preview="Conferma Pagamento">
<>
<Heading className="text-center text-3xl leading-8">
Conferma Pagamento
</Heading>
<Section className="px-5 text-left text-base md:px-12">
<Row>
<Text>
Gentile Cliente, la informiamo che il pagamento è stato confermato
con successo. Procederemo ad attivare i servizi acquistati se non
lo fossero già. Può controllare lo stato del suo account nella
sezione dedicata.
</Text>
</Row>
<Section className="mb-5">
<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"
href={receiptHref}
>
Vai al tuo account
</Button>
</Section>
</Section>
</>
</Base>
);
return (
<Base preview="Conferma Pagamento">
<>
<Heading className="text-center text-3xl leading-8">
Conferma Pagamento
</Heading>
<Section className="px-5 text-left text-base md:px-12">
<Row>
<Text>
Gentile Cliente, la informiamo che il pagamento è stato confermato
con successo. Procederemo ad attivare i servizi acquistati se non
lo fossero già. Può controllare lo stato del suo account nella
sezione dedicata.
</Text>
</Row>
<Section className="mb-5">
<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"
href={receiptHref}
>
Vai al tuo account
</Button>
</Section>
</Section>
</>
</Base>
);
};
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";
export type PwResetLink_NewMail = {
mailType: "pwResetLink";
props: PwResetLinkProps;
mailType: "pwResetLink";
props: PwResetLinkProps;
};
type PwResetLinkProps = {
resetlink: string;
resetlink: string;
};
const PwResetLink = ({ resetlink }: PwResetLinkProps) => {
return (
<Base preview="Link Reset Password">
<>
<Heading className="text-center text-3xl leading-8">
Link Reset Password
</Heading>
<Section className="px-5 text-left text-base md:px-12">
<Row>
<Text>
Abbiamo ricevuto la tua richiesta di reset password, clicca sul
link sottostante per procedere.
</Text>
</Row>
<Section className="mb-5">
<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"
href={resetlink}
>
Clicca qui per il reset della password
</Button>
</Section>
</Section>
</>
</Base>
);
return (
<Base preview="Link Reset Password">
<>
<Heading className="text-center text-3xl leading-8">
Link Reset Password
</Heading>
<Section className="px-5 text-left text-base md:px-12">
<Row>
<Text>
Abbiamo ricevuto la tua richiesta di reset password, clicca sul
link sottostante per procedere.
</Text>
</Row>
<Section className="mb-5">
<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"
href={resetlink}
>
Clicca qui per il reset della password
</Button>
</Section>
</Section>
</>
</Base>
);
};
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",
"project": [
"src/**",
"**",
"!src/schemas/public/*.ts",
"!emails/**"
],
"ignore": [
".kanelrc.js",
".eslintrc.json",
"TypeHelpers.type.ts",
"src/utils/api.ts",
"src/server/api/trpc.ts",
"check-env-vars.js",
"src/components/ui/*",
"src/hooks/coordDistance.ts",
"src/hooks/useMobile.tsx",
"src/lib/nossr.tsx",
"src/lib/useIsTyping.ts",
"src/middlewares/apis_middleware.ts",
"src/server/auth.ts",
"src/utils/get-media-url.ts",
"src/utils/utils.ts"
],
"ignoreBinaries": [
"stripe"
],
"ignoreDependencies": [
"kysely-plugin-serialize",
"sharp",
"uuid",
"@react-email/components",
"@eslint/compat",
"@next/eslint-plugin-next",
"@prettier/sync",
"@types/eslint",
"@types/uuid",
"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"
]
}
"$schema": "https://unpkg.com/knip@5/schema.json",
"project": ["src/**", "**", "!src/schemas/public/*.ts", "!emails/**"],
"ignore": [
".kanelrc.js",
"TypeHelpers.type.ts",
"src/utils/api.ts",
"src/server/api/trpc.ts",
"check-env-vars.js",
"src/components/ui/*",
"src/hooks/coordDistance.ts",
"src/hooks/useMobile.tsx",
"src/lib/nossr.tsx",
"src/lib/useIsTyping.ts",
"src/middlewares/apis_middleware.ts",
"src/server/auth.ts",
"src/utils/get-media-url.ts",
"src/utils/utils.ts"
],
"ignoreBinaries": ["stripe"],
"ignoreDependencies": [
"kysely-plugin-serialize",
"sharp",
"uuid",
"@react-email/components",
"@types/uuid",
"kanel-kysely",
"npm-run-all",
"@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} */
const nextConfig = {
output: env.NODE_ENV === "production" ? "standalone" : undefined, // This is for docker builds
reactStrictMode: true,
compiler: {
removeConsole: false,
},
i18n: {
locales: ["it", "en"],
defaultLocale: "it",
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.googleusercontent.com",
port: "",
pathname: "**",
},
{
hostname: env.BACKENDSERVER_URL || "localhost:1323",
},
],
minimumCacheTTL: 86400, // 1 day in seconds
},
output: env.NODE_ENV === "production" ? "standalone" : undefined, // This is for docker builds
reactStrictMode: true,
compiler: {
removeConsole: false,
},
i18n: {
locales: ["it", "en"],
defaultLocale: "it",
},
images: {
remotePatterns: [
{
protocol: "https",
hostname: "*.googleusercontent.com",
port: "",
pathname: "**",
},
{
hostname: env.BACKENDSERVER_URL || "localhost:1323",
},
],
minimumCacheTTL: 86400, // 1 day in seconds
},
typescript: {
ignoreBuildErrors: true,
},
eslint: {
ignoreDuringBuilds: true,
},
devIndicators: false,
rewrites: async () => {
return [
{
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/storage/get/:slug*",
destination: env.BACKENDSERVER_URL + "/storage/get/:slug*",
has: [
{ type: "cookie", key: "access_token" },
{
type: "query",
key: "token",
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*",
has: [
{ type: "cookie", key: "access_token" },
{
type: "query",
key: "token",
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 [
{
source: "/:path*",
headers: [
{
key: "X-Frame-Options",
value: "",
},
],
},
];
},
typescript: {
ignoreBuildErrors: true,
},
devIndicators: false,
rewrites: async () => {
return [
{
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/storage/get/:slug*",
destination: `${env.BACKENDSERVER_URL}/storage/get/:slug*`,
has: [
{ type: "cookie", key: "access_token" },
{
type: "query",
key: "token",
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*`,
has: [
{ type: "cookie", key: "access_token" },
{
type: "query",
key: "token",
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 [
{
source: "/:path*",
headers: [
{
key: "X-Frame-Options",
value: "",
},
],
},
];
},
};
export default nextConfig;

File diff suppressed because it is too large Load diff

View file

@ -101,17 +101,9 @@
},
"devDependencies": {
"@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",
"@next/eslint-plugin-next": "^15.4.5",
"@prettier/sync": "^0.6.1",
"@react-email/preview-server": "^4.2.8",
"@tanstack/eslint-plugin-query": "^5.83.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/leaflet": "^1.9.20",
"@types/node": "^24.2.0",
@ -122,27 +114,13 @@
"@types/react-dom": "^19.1.7",
"@types/react-is": "^19.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",
"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",
"kanel": "^3.14.2",
"kanel-kysely": "^0.7.1",
"knip": "^5.62.0",
"npm-run-all": "^4.1.5",
"prettier": "^3.6.2",
"prettier-plugin-classnames": "^0.8.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"typescript": "^5.9.2",
"typescript-eslint": "^8.39.0"
"typescript": "^5.9.2"
},
"ct3aMetadata": {
"initVersion": "7.9.0"

View file

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

View file

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

View file

@ -1,238 +1,238 @@
import {
Accessibility,
ArrowLeft,
ArrowRight,
Baby,
BellRing,
Building,
Building2,
CircleDot,
CircleParking,
CirclePlus,
CircleX,
Cog,
Contact,
Cross,
Dot,
Euro,
FerrisWheel,
Film,
GraduationCap,
Heart,
Heater,
House,
Info,
LayoutDashboard,
Mail,
MessageSquareText,
MessagesSquare,
Milestone,
Paperclip,
Pointer,
Search,
ShoppingCart,
SlidersHorizontal,
Sofa,
Star,
Target,
ThermometerSun,
ThumbsUp,
Trees,
TriangleAlert,
User,
Users,
Wifi,
type LucideProps,
Accessibility,
ArrowLeft,
ArrowRight,
Baby,
BellRing,
Building,
Building2,
CircleDot,
CircleParking,
CirclePlus,
CircleX,
Cog,
Contact,
Cross,
Dot,
Euro,
FerrisWheel,
Film,
GraduationCap,
Heart,
Heater,
House,
Info,
LayoutDashboard,
type LucideProps,
Mail,
MessageSquareText,
MessagesSquare,
Milestone,
Paperclip,
Pointer,
Search,
ShoppingCart,
SlidersHorizontal,
Sofa,
Star,
Target,
ThermometerSun,
ThumbsUp,
Trees,
TriangleAlert,
User,
Users,
Wifi,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
import toast from "react-hot-toast";
import type {
ComponentPropsWithoutRef,
ForwardRefExoticComponent,
RefAttributes,
ComponentPropsWithoutRef,
ForwardRefExoticComponent,
RefAttributes,
} from "react";
import toast from "react-hot-toast";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "~/components/ui/tooltip";
const iconOptions = [
"heater",
"error",
"warning",
"sofa",
"building",
"building2",
"wifi",
"accessibility",
"baby",
"thermometer-sun",
"circle-plus",
"circle-dot",
"dot",
"cog",
"check",
"search",
"contact",
"euro",
"dashboard",
"settings",
"user",
"users",
"messages",
"message",
"arrowR",
"arrowL",
"attachment",
"cart",
"star",
"house",
"heart",
"info",
"mail",
"school",
"cross",
"park",
"parking",
"film",
"milestone",
"entertainment",
"circle-help",
"bell",
"target",
"thumbs-up",
"heater",
"error",
"warning",
"sofa",
"building",
"building2",
"wifi",
"accessibility",
"baby",
"thermometer-sun",
"circle-plus",
"circle-dot",
"dot",
"cog",
"check",
"search",
"contact",
"euro",
"dashboard",
"settings",
"user",
"users",
"messages",
"message",
"arrowR",
"arrowL",
"attachment",
"cart",
"star",
"house",
"heart",
"info",
"mail",
"school",
"cross",
"park",
"parking",
"film",
"milestone",
"entertainment",
"circle-help",
"bell",
"target",
"thumbs-up",
] as const;
export type IconType = (typeof iconOptions)[number];
type LucidePropsType = ForwardRefExoticComponent<
Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>
Omit<LucideProps, "ref"> & RefAttributes<SVGSVGElement>
>;
interface IconMatrixProps extends ComponentPropsWithoutRef<LucidePropsType> {
type: IconType;
type: IconType;
}
export const IconMatrix = ({ type, ...rest }: IconMatrixProps) => {
switch (type) {
case "heater":
return <Heater {...rest} />;
case "error":
return <CircleX {...rest} />;
case "warning":
return <TriangleAlert {...rest} />;
case "sofa":
return <Sofa {...rest} />;
case "building":
return <Building {...rest} />;
case "building2":
return <Building2 {...rest} />;
case "wifi":
return <Wifi {...rest} />;
case "accessibility":
return <Accessibility {...rest} />;
case "baby":
return <Baby {...rest} />;
case "thermometer-sun":
return <ThermometerSun {...rest} />;
case "circle-plus":
return <CirclePlus {...rest} />;
case "circle-dot":
return <CircleDot {...rest} />;
case "dot":
return <Dot {...rest} />;
case "cog":
return <Cog {...rest} />;
case "check":
return <CheckCircleIcon {...rest} />;
case "search":
return <Search {...rest} />;
case "contact":
return <Contact {...rest} />;
case "euro":
return <Euro {...rest} />;
case "dashboard":
return <LayoutDashboard {...rest} />;
case "settings":
return <SlidersHorizontal {...rest} />;
case "user":
return <User {...rest} />;
case "users":
return <Users {...rest} />;
case "messages":
return <MessagesSquare {...rest} />;
case "message":
return <MessageSquareText {...rest} />;
case "arrowR":
return <ArrowRight {...rest} />;
case "arrowL":
return <ArrowLeft {...rest} />;
case "attachment":
return <Paperclip {...rest} />;
case "cart":
return <ShoppingCart {...rest} />;
case "star":
return <Star {...rest} />;
case "house":
return <House {...rest} />;
case "heart":
return <Heart {...rest} />;
case "info":
return <Info {...rest} />;
case "mail":
return <Mail {...rest} />;
case "school":
return <GraduationCap {...rest} />;
case "cross":
return <Cross {...rest} />;
case "park":
return <Trees {...rest} />;
case "parking":
return <CircleParking {...rest} />;
case "film":
return <Film {...rest} />;
case "milestone":
return <Milestone {...rest} />;
case "entertainment":
return <FerrisWheel {...rest} />;
case "circle-help":
return <QuestionMarkCircleIcon {...rest} />;
case "bell":
return <BellRing {...rest} />;
case "target":
return <Target {...rest} />;
case "thumbs-up":
return <ThumbsUp {...rest} />;
}
switch (type) {
case "heater":
return <Heater {...rest} />;
case "error":
return <CircleX {...rest} />;
case "warning":
return <TriangleAlert {...rest} />;
case "sofa":
return <Sofa {...rest} />;
case "building":
return <Building {...rest} />;
case "building2":
return <Building2 {...rest} />;
case "wifi":
return <Wifi {...rest} />;
case "accessibility":
return <Accessibility {...rest} />;
case "baby":
return <Baby {...rest} />;
case "thermometer-sun":
return <ThermometerSun {...rest} />;
case "circle-plus":
return <CirclePlus {...rest} />;
case "circle-dot":
return <CircleDot {...rest} />;
case "dot":
return <Dot {...rest} />;
case "cog":
return <Cog {...rest} />;
case "check":
return <CheckCircleIcon {...rest} />;
case "search":
return <Search {...rest} />;
case "contact":
return <Contact {...rest} />;
case "euro":
return <Euro {...rest} />;
case "dashboard":
return <LayoutDashboard {...rest} />;
case "settings":
return <SlidersHorizontal {...rest} />;
case "user":
return <User {...rest} />;
case "users":
return <Users {...rest} />;
case "messages":
return <MessagesSquare {...rest} />;
case "message":
return <MessageSquareText {...rest} />;
case "arrowR":
return <ArrowRight {...rest} />;
case "arrowL":
return <ArrowLeft {...rest} />;
case "attachment":
return <Paperclip {...rest} />;
case "cart":
return <ShoppingCart {...rest} />;
case "star":
return <Star {...rest} />;
case "house":
return <House {...rest} />;
case "heart":
return <Heart {...rest} />;
case "info":
return <Info {...rest} />;
case "mail":
return <Mail {...rest} />;
case "school":
return <GraduationCap {...rest} />;
case "cross":
return <Cross {...rest} />;
case "park":
return <Trees {...rest} />;
case "parking":
return <CircleParking {...rest} />;
case "film":
return <Film {...rest} />;
case "milestone":
return <Milestone {...rest} />;
case "entertainment":
return <FerrisWheel {...rest} />;
case "circle-help":
return <QuestionMarkCircleIcon {...rest} />;
case "bell":
return <BellRing {...rest} />;
case "target":
return <Target {...rest} />;
case "thumbs-up":
return <ThumbsUp {...rest} />;
}
};
export const IconMatrixTooltip = () => {
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1" type="button">
Lista Icone <Pointer className="size-4" />
</TooltipTrigger>
<TooltipContent>
<div className="grid grid-cols-4 gap-2">
{iconOptions.map((icon) => (
<button
type="button"
onClick={async () => {
await navigator.clipboard.writeText(icon);
toast("Copiato", { icon: "📋" });
}}
key={icon}
className="bg-muted flex flex-col items-center rounded-xl p-2 hover:opacity-75"
>
<IconMatrix type={icon} className="size-6" />
<span className="text-sm">{icon}</span>
</button>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
return (
<TooltipProvider delayDuration={300}>
<Tooltip>
<TooltipTrigger className="flex flex-row gap-1" type="button">
Lista Icone <Pointer className="size-4" />
</TooltipTrigger>
<TooltipContent>
<div className="grid grid-cols-4 gap-2">
{iconOptions.map((icon) => (
<button
type="button"
onClick={async () => {
await navigator.clipboard.writeText(icon);
toast("Copiato", { icon: "📋" });
}}
key={icon}
className="bg-muted flex flex-col items-center rounded-xl p-2 hover:opacity-75"
>
<IconMatrix type={icon} className="size-6" />
<span className="text-sm">{icon}</span>
</button>
))}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
/*
type IRP_readOnly = {
@ -305,70 +305,74 @@ export const IconRatingComonent = ({
};
*/
export const ShieldExclamationIcon = ({
className,
className,
}: {
className?: string;
className?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path
fillRule="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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>Shield</title>
<path
fillRule="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>
);
export const QuestionMarkCircleIcon = ({
className,
className,
}: {
className?: string;
className?: string;
}) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path
fillRule="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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>QuestionMark</title>
<path
fillRule="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>
);
export const HomeIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
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
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<title>Home</title>
<path
strokeLinecap="round"
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>
);
const CheckCircleIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<path
fillRule="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
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
className={className}
>
<title>CheckCircle</title>
<path
fillRule="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>
);

View file

@ -1,19 +1,19 @@
import Image from "next/image";
import { type ComponentProps } from "react";
import type { ComponentProps } from "react";
const fallbackImage = "/fallback-image.png";
export const ImageFlbk = (props: ComponentProps<typeof Image>) => {
return (
<Image
{...props}
onLoad={(e) => {
if (e.currentTarget.naturalWidth === 0) {
e.currentTarget.src = fallbackImage;
}
}}
onError={(e) => {
e.currentTarget.src = fallbackImage;
}}
/>
);
return (
<Image
{...props}
onLoad={(e) => {
if (e.currentTarget.naturalWidth === 0) {
e.currentTarget.src = fallbackImage;
}
}}
onError={(e) => {
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 Link from "next/link";
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>
<div className="relative">
@ -19,70 +20,70 @@ import type { ReactNode } from "react";
*/
type ChildrenBubbleProps = {
children: ReactNode;
href?: undefined;
txt?: undefined;
target?: undefined;
children: ReactNode;
href?: undefined;
txt?: undefined;
target?: undefined;
};
type LinkBubbleProps = {
children?: undefined;
href: string;
txt: string;
target?: "_self" | "_blank" | "_parent" | "_top";
children?: undefined;
href: string;
txt: string;
target?: "_self" | "_blank" | "_parent" | "_top";
};
type InformationBubbleProps = {
className?: string;
iconClassName?: string;
icon?: IconType;
side?: "top" | "bottom" | "left" | "right";
className?: string;
iconClassName?: string;
icon?: IconType;
side?: "top" | "bottom" | "left" | "right";
} & (ChildrenBubbleProps | LinkBubbleProps);
const InformationBubble = ({
className,
icon = "circle-help",
iconClassName,
side,
children,
href,
txt,
target,
className,
icon = "circle-help",
iconClassName,
side,
children,
href,
txt,
target,
}: InformationBubbleProps) => {
return (
<TooltipProvider delayDuration={0}>
<HybridTooltip>
<HybridTooltipTrigger
className={cn(
`absolute -top-2 -right-2 flex size-6 items-center justify-center rounded-full bg-white`,
className,
)}
>
<IconMatrix
type={icon}
className={cn("size-6 text-indigo-500", iconClassName)}
/>
</HybridTooltipTrigger>
return (
<TooltipProvider delayDuration={0}>
<HybridTooltip>
<HybridTooltipTrigger
className={cn(
`absolute -top-2 -right-2 flex size-6 items-center justify-center rounded-full bg-white`,
className,
)}
>
<IconMatrix
type={icon}
className={cn("size-6 text-indigo-500", iconClassName)}
/>
</HybridTooltipTrigger>
<HybridTooltipContent
side={side}
className="border-border -mb-1.5 bg-white shadow-sm [&>span>svg]:invisible"
>
{href ? (
<Link
href={href}
target={target}
className="flex items-center justify-center text-sm text-blue-500 underline-offset-2 hover:underline"
>
<ExternalLink className="mr-1 size-4" />
<p>{txt}</p>
</Link>
) : (
<>{children}</>
)}
</HybridTooltipContent>
</HybridTooltip>
</TooltipProvider>
);
<HybridTooltipContent
side={side}
className="border-border -mb-1.5 bg-white shadow-sm [&>span>svg]:invisible"
>
{href ? (
<Link
href={href}
target={target}
className="flex items-center justify-center text-sm text-blue-500 underline-offset-2 hover:underline"
>
<ExternalLink className="mr-1 size-4" />
<p>{txt}</p>
</Link>
) : (
<>{children}</>
)}
</HybridTooltipContent>
</HybridTooltip>
</TooltipProvider>
);
};
export default InformationBubble;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,34 +1,34 @@
import { getTitoloTranslation } from "~/lib/annuncio_details";
import { CardAnnuncio } from "~/components/annuncio_card";
import { getTitoloTranslation } from "~/lib/annuncio_details";
import { useTranslation } from "~/providers/I18nProvider";
import type { AnnuncioRicerca } from "~/server/controllers/annunci.controller";
export const AnnunciGrid = ({ pagedata }: { pagedata: AnnuncioRicerca[] }) => {
const { locale } = useTranslation();
const { locale } = useTranslation();
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">
{pagedata.map((annuncio) => (
<CardAnnuncio
id={annuncio.id}
key={annuncio.codice}
codice={annuncio.codice}
prezzo={annuncio.prezzo}
titolo={getTitoloTranslation({
locale: locale,
titolo_it: annuncio.titolo_it,
titolo_en: annuncio.titolo_en,
})}
mq={annuncio.mq}
comune={annuncio.comune}
provincia={annuncio.provincia}
consegna={annuncio.consegna}
camere={annuncio.numero_camere}
immagini={annuncio.url_immagini || undefined}
tipo={annuncio.tipo}
stato={annuncio.stato}
/>
))}
</div>
);
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">
{pagedata.map((annuncio) => (
<CardAnnuncio
id={annuncio.id}
key={annuncio.codice}
codice={annuncio.codice}
prezzo={annuncio.prezzo}
titolo={getTitoloTranslation({
locale: locale,
titolo_it: annuncio.titolo_it,
titolo_en: annuncio.titolo_en,
})}
mq={annuncio.mq}
comune={annuncio.comune}
provincia={annuncio.provincia}
consegna={annuncio.consegna}
camere={annuncio.numero_camere}
immagini={annuncio.url_immagini || undefined}
tipo={annuncio.tipo}
stato={annuncio.stato}
/>
))}
</div>
);
};

View file

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

View file

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

View file

@ -1,208 +1,208 @@
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 { 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 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 { ContattoAnnuncio } from "~/components/annuncio-interactions/contatto_modal";
import toast from "react-hot-toast";
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
export const AnnuncioInteractions = ({
session,
disabled,
session,
disabled,
}: {
session: SessionContextType;
disabled?: boolean;
session: SessionContextType;
disabled?: boolean;
}) => {
const router = useRouter();
const { id, tipo } = useAnnuncio();
const router = useRouter();
const { id, tipo } = useAnnuncio();
if (disabled) {
return (
<div className="w-full rounded-md bg-red-500 p-2 text-center text-base font-semibold text-white">
Richieste Temporaneamente disattivate
</div>
);
}
return (
<div className="flex w-full flex-col justify-around gap-3 md:gap-5">
{session.user ? (
tipo ? (
<ServizioInteraction
userId={session.user.id}
tipologia={tipo}
annuncioId={id}
/>
) : (
<span>Errore</span>
)
) : (
<div className="flex w-full flex-col gap-2">
<Link
aria-label="Login"
className={cn(buttonVariants({ variant: "default" }))}
href={{
pathname: "/login",
query: { redirect: router.asPath },
}}
>
<LogIn className="mr-1 size-7" /> Accedi se hai un account
</Link>
<span className="w-full text-center text-sm">oppure</span>
<ContattoAnnuncio />
</div>
)}
</div>
);
if (disabled) {
return (
<div className="w-full rounded-md bg-red-500 p-2 text-center text-base font-semibold text-white">
Richieste Temporaneamente disattivate
</div>
);
}
return (
<div className="flex w-full flex-col justify-around gap-3 md:gap-5">
{session.user ? (
tipo ? (
<ServizioInteraction
userId={session.user.id}
tipologia={tipo}
annuncioId={id}
/>
) : (
<span>Errore</span>
)
) : (
<div className="flex w-full flex-col gap-2">
<Link
aria-label="Login"
className={cn(buttonVariants({ variant: "default" }))}
href={{
pathname: "/login",
query: { redirect: router.asPath },
}}
>
<LogIn className="mr-1 size-7" /> Accedi se hai un account
</Link>
<span className="w-full text-center text-sm">oppure</span>
<ContattoAnnuncio />
</div>
)}
</div>
);
};
const ServizioInteraction = ({
userId,
tipologia,
annuncioId,
userId,
tipologia,
annuncioId,
}: {
userId: UsersId;
tipologia: string;
annuncioId: AnnunciId;
userId: UsersId;
tipologia: string;
annuncioId: AnnunciId;
}) => {
const { data: servizio, isLoading } =
api.servizio.AnnuncioServizioTipologiaMatch.useQuery({
userId,
tipologia,
annuncioId,
});
const { data: servizio, isLoading } =
api.servizio.AnnuncioServizioTipologiaMatch.useQuery({
userId,
tipologia,
annuncioId,
});
if (isLoading) return <LoadingButton loading className="w-full" />;
if (
servizio == undefined ||
servizio.status == "invalid" ||
servizio.status == "not_found"
) {
return (
<div>
<p>Non hai un servizio attivo</p>
</div>
);
}
if (isLoading) return <LoadingButton loading className="w-full" />;
if (
servizio === undefined ||
servizio.status === "invalid" ||
servizio.status === "not_found"
) {
return (
<div>
<p>Non hai un servizio attivo</p>
</div>
);
}
if (servizio.status == "already_saved") {
return (
<div className="flex w-full flex-col gap-2">
<div className="w-full text-center font-semibold text-green-500">
Annuncio già salvato!
</div>
<Link
href={`/area-riservata/dashboard`}
aria-label="Vai alla tua area riservata"
>
<Button variant="success" className="flex w-full items-center gap-2">
Vai alla tua area riservata <ArrowRight />
</Button>
</Link>
</div>
);
}
if (servizio.status === "already_saved") {
return (
<div className="flex w-full flex-col gap-2">
<div className="w-full text-center font-semibold text-green-500">
Annuncio già salvato!
</div>
<Link
href={`/area-riservata/dashboard`}
aria-label="Vai alla tua area riservata"
>
<Button variant="success" className="flex w-full items-center gap-2">
Vai alla tua area riservata <ArrowRight />
</Button>
</Link>
</div>
);
}
return <InteressatoButton annuncioId={annuncioId} />;
return <InteressatoButton annuncioId={annuncioId} />;
};
export const InteressatoButton = ({
annuncioId,
annuncioId,
}: {
annuncioId: AnnunciId;
annuncioId: AnnunciId;
}) => {
const { data: hasInterest, isLoading } =
api.intrests.hasUserInterest.useQuery({
annuncioId,
});
const utils = api.useUtils();
const { mutate: add } = api.intrests.addIntrest.useMutation({
onSuccess: async () => {
await utils.intrests.hasUserInterest.invalidate({
annuncioId,
});
toast.success("Richiesta di interesse inviata con successo!");
},
});
const { data: hasInterest, isLoading } =
api.intrests.hasUserInterest.useQuery({
annuncioId,
});
const utils = api.useUtils();
const { mutate: add } = api.intrests.addIntrest.useMutation({
onSuccess: async () => {
await utils.intrests.hasUserInterest.invalidate({
annuncioId,
});
toast.success("Richiesta di interesse inviata con successo!");
},
});
if (isLoading || hasInterest === undefined) {
return (
<Button className="flex w-full items-center gap-2" disabled>
<BadgePlus className="size-6" />
Caricamento...
</Button>
);
}
if (hasInterest) {
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">
<BadgePlus className="inline size-5" />
Hai già mandato una richiesta
</span>
);
}
if (isLoading || hasInterest === undefined) {
return (
<Button className="flex w-full items-center gap-2" disabled>
<BadgePlus className="size-6" />
Caricamento...
</Button>
);
}
if (hasInterest) {
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">
<BadgePlus className="inline size-5" />
Hai già mandato una richiesta
</span>
);
}
return (
<Button
className="flex w-full items-center gap-2"
variant="info"
//size="xl"
aria-label="Sono interessato"
onClick={() => {
add({
annuncioId,
});
}}
>
<BadgePlus className="size-6" />
Sono interessato
</Button>
);
return (
<Button
className="flex w-full items-center gap-2"
variant="info"
//size="xl"
aria-label="Sono interessato"
onClick={() => {
add({
annuncioId,
});
}}
>
<BadgePlus className="size-6" />
Sono interessato
</Button>
);
};
export const InteressatoButtonBatchV = ({
annuncioId,
hasInterest,
annuncioId,
hasInterest,
}: {
annuncioId: AnnunciId;
hasInterest: boolean;
annuncioId: AnnunciId;
hasInterest: boolean;
}) => {
const utils = api.useUtils();
const { mutate: add } = api.intrests.addIntrest.useMutation({
onSuccess: async () => {
await utils.intrests.getUserInterests.invalidate();
toast.success("Richiesta di interesse inviata con successo!");
},
});
const utils = api.useUtils();
const { mutate: add } = api.intrests.addIntrest.useMutation({
onSuccess: async () => {
await utils.intrests.getUserInterests.invalidate();
toast.success("Richiesta di interesse inviata con successo!");
},
});
if (hasInterest) {
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">
<BadgePlus className="inline size-5" />
Hai già mandato una richiesta
</span>
);
}
if (hasInterest) {
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">
<BadgePlus className="inline size-5" />
Hai già mandato una richiesta
</span>
);
}
return (
<Button
className="flex w-full items-center gap-2"
variant="info"
//size="xl"
aria-label="Sono interessato"
onClick={() => {
add({
annuncioId,
});
}}
>
<BadgePlus className="size-6" />
Sono interessato
</Button>
);
return (
<Button
className="flex w-full items-center gap-2"
variant="info"
//size="xl"
aria-label="Sono interessato"
onClick={() => {
add({
annuncioId,
});
}}
>
<BadgePlus className="size-6" />
Sono interessato
</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 {
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 = () => {
const { codice } = useAnnuncio();
const { codice } = useAnnuncio();
return (
<Credenza>
<CredenzaTrigger asChild>
<Button
variant="destructive"
className="flex w-full items-center gap-2"
>
<MessageCircleQuestion className="size-6" /> Contattaci
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]">
<CredenzaHeader>
<CredenzaTitle>Contattaci per avere informazioni</CredenzaTitle>
<CredenzaDescription>
Ti contatteremo il prima possibile per darti più informazioni
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<FormContattoAnnucio codice={codice} />
</CredenzaBody>
</CredenzaContent>
</Credenza>
);
return (
<Credenza>
<CredenzaTrigger asChild>
<Button
variant="destructive"
className="flex w-full items-center gap-2"
>
<MessageCircleQuestion className="size-6" /> Contattaci
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]">
<CredenzaHeader>
<CredenzaTitle>Contattaci per avere informazioni</CredenzaTitle>
<CredenzaDescription>
Ti contatteremo il prima possibile per darti più informazioni
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] overflow-auto pb-5">
<FormContattoAnnucio codice={codice} />
</CredenzaBody>
</CredenzaContent>
</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 Image from "next/image";
import { useTranslation } from "~/providers/I18nProvider";
import Link from "next/link";
import { useState } from "react";
import { ImageFlbk } from "~/components/ImageWithFallback";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog";
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 = {
id: number;
codice: string;
prezzo: number;
titolo: string | null;
mq: string | null;
comune: string | null;
provincia: string | null;
consegna: number | null;
camere: number | null;
immagini: string[] | undefined;
tipo: string | null;
stato: string | null;
className?: string;
id: number;
codice: string;
prezzo: number;
titolo: string | null;
mq: string | null;
comune: string | null;
provincia: string | null;
consegna: number | null;
camere: number | null;
immagini: string[] | undefined;
tipo: string | null;
stato: string | null;
className?: string;
};
export const CardAnnuncio = ({
id,
codice,
prezzo,
titolo,
mq,
comune,
provincia,
consegna,
camere,
immagini,
tipo,
stato,
className,
id,
codice,
prezzo,
titolo,
mq,
comune,
provincia,
consegna,
camere,
immagini,
tipo,
stato,
className,
}: CardAnnuncioProps) => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<div
className={cn(
"h-[464px] rounded-xl bg-white shadow-sm shadow-neutral-100 outline outline-neutral-200 hover:shadow-md hover:outline-neutral-300",
className,
)}
id={`card-annuncio-${id}`}
>
<Link
href={`/annuncio/${codice}`}
//target="_blank"
className="block p-2" //duration-700 ease-in-out animate-in fade-in
>
<div className="group relative rounded-xl text-clip">
{stato == "Trattativa" && (
<div>
<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">
Annuncio in Trattativa
</div>
</div>
</div>
)}
return (
<div
className={cn(
"h-[464px] rounded-xl bg-white shadow-sm shadow-neutral-100 outline outline-neutral-200 hover:shadow-md hover:outline-neutral-300",
className,
)}
id={`card-annuncio-${id}`}
>
<Link
href={`/annuncio/${codice}`}
//target="_blank"
className="block p-2" //duration-700 ease-in-out animate-in fade-in
>
<div className="group relative rounded-xl text-clip">
{stato === "Trattativa" && (
<div>
<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">
Annuncio in Trattativa
</div>
</div>
</div>
)}
<Carousel opts={{ loop: true }}>
<CarouselContent>
{immagini &&
immagini.map((img, idx) => (
<CarouselItem key={`${img}-${idx}`}>
<ImageFlbk
alt={t.card.alt_immagine}
width={800}
height={400}
src={img}
priority={idx === 0}
className={
"h-56 w-full rounded-xl object-cover outline outline-neutral-200"
}
/>
</CarouselItem>
))}
</CarouselContent>
<Carousel opts={{ loop: true }}>
<CarouselContent>
{immagini?.map((img, idx) => (
<CarouselItem key={`${img}-${idx}`}>
<ImageFlbk
alt={t.card.alt_immagine}
width={800}
height={400}
src={img}
priority={idx === 0}
className={
"h-56 w-full rounded-xl object-cover outline outline-neutral-200"
}
/>
</CarouselItem>
))}
</CarouselContent>
<div
onKeyDown={(e) => e.stopPropagation()}
role="button"
tabIndex={0}
onClick={(e) => e.preventDefault()}
className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0"
>
<CarouselPrevious
className="left-2 cursor-pointer"
disabled={!immagini || immagini.length === 1}
/>
<CarouselNext
className="right-2 cursor-pointer"
disabled={!immagini || immagini.length === 1}
/>
</div>
</Carousel>
</div>
<div
className={cn(
"mt-2 flex w-full items-center justify-center rounded-md",
tipo === "Transitorio" && "bg-transitorio",
tipo === "Stabile" && "bg-stabile",
tipo === "Cessione" && "bg-blue-400",
tipo === "Vendita" && "bg-green-400",
)}
>
<div className="font-semibold text-white">{tipo}</div>
</div>
<div className="mt-2 space-y-2">
<div className="flex flex-row justify-around">
<div className="text-center">
<span className="sr-only">{t.card.codice}</span>
<div
onKeyDown={(e) => e.stopPropagation()}
role="button"
tabIndex={0}
onClick={(e) => e.preventDefault()}
className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0"
>
<CarouselPrevious
className="left-2 cursor-pointer"
disabled={!immagini || immagini.length === 1}
/>
<CarouselNext
className="right-2 cursor-pointer"
disabled={!immagini || immagini.length === 1}
/>
</div>
</Carousel>
</div>
<div
className={cn(
"mt-2 flex w-full items-center justify-center rounded-md",
tipo === "Transitorio" && "bg-transitorio",
tipo === "Stabile" && "bg-stabile",
tipo === "Cessione" && "bg-blue-400",
tipo === "Vendita" && "bg-green-400",
)}
>
<div className="font-semibold text-white">{tipo}</div>
</div>
<div className="mt-2 space-y-2">
<div className="flex flex-row justify-around">
<div className="text-center">
<span className="sr-only">{t.card.codice}</span>
<span className="text-xl font-semibold text-red-600">
Cod: {codice}
</span>
</div>
<div className="text-center">
<span className="sr-only">{t.card.prezzo}</span>
<span className="text-xl font-semibold text-red-600">
Cod: {codice}
</span>
</div>
<div className="text-center">
<span className="sr-only">{t.card.prezzo}</span>
<span className="text-xl font-semibold">
{formatCurrency(prezzo / 1e2)}
</span>
</div>
</div>
<span className="text-xl font-semibold">
{formatCurrency(prezzo / 1e2)}
</span>
</div>
</div>
<div className="text-muted-foreground mb-3 text-center">
<span className="sr-only">{t.card.titolo}</span>
<div className="text-muted-foreground mb-3 text-center">
<span className="sr-only">{t.card.titolo}</span>
<span className="line-clamp-2 min-h-[3.5rem] text-base font-medium md:text-lg">
{titolo}
</span>
</div>
<div className="text-muted-foreground text-center">
<span className="sr-only">{t.card.indirizzo}</span>
<span className="line-clamp-2 min-h-[3.5rem] text-base font-medium md:text-lg">
{titolo}
</span>
</div>
<div className="text-muted-foreground text-center">
<span className="sr-only">{t.card.indirizzo}</span>
<span className="text-sm font-medium">
{comune && provincia && `${comune} (${provincia})`}
</span>
</div>
</div>
<span className="text-sm font-medium">
{comune && provincia && `${comune} (${provincia})`}
</span>
</div>
</div>
<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">
<Ruler className="size-4 text-indigo-700" />
<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">
<Ruler className="size-4 text-indigo-700" />
<div>
<p className="font-medium">
{mq ? Number.parseFloat(mq) : 0}{" "}
<span>
m<sup>2</sup>
</span>
</p>
</div>
</div>
<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" />
<div>
<p className="font-medium">
{mq ? Number.parseFloat(mq) : 0}{" "}
<span>
m<sup>2</sup>
</span>
</p>
</div>
</div>
<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" />
<div>
<p className="font-medium">
{camereTesti({ camere: camere, testi: t.card })}
</p>
</div>
</div>
<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" />
<div>
<p className="font-medium">
{camereTesti({ camere: camere, testi: t.card })}
</p>
</div>
</div>
<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" />
<div>
<p className="truncate font-medium">
{handleConsegna({
consegna: consegna,
consegna_da: t.card.consegna_da,
mesi: t.preferenze.mesi,
subito: t.card.consegna_subito,
aggiornamento: t.card.in_aggiornamento,
})}
</p>
</div>
</div>
</div>
</Link>
</div>
);
<div>
<p className="truncate font-medium">
{handleConsegna({
consegna: consegna,
consegna_da: t.card.consegna_da,
mesi: t.preferenze.mesi,
subito: t.card.consegna_subito,
aggiornamento: t.card.in_aggiornamento,
})}
</p>
</div>
</div>
</div>
</Link>
</div>
);
};
export const CarouselAnnuncio = ({
single,
immagini,
single,
immagini,
}: {
single?: boolean;
immagini: string[] | null;
single?: boolean;
immagini: string[] | null;
}) => {
const [openModal, setOpenModal] = useState(false);
const [idxModal, setIdxModal] = useState(0);
function handleOpenModal(position: number) {
setIdxModal(position);
setOpenModal(true);
}
const [openModal, setOpenModal] = useState(false);
const [idxModal, setIdxModal] = useState(0);
function handleOpenModal(position: number) {
setIdxModal(position);
setOpenModal(true);
}
return (
<>
<div className="mx-auto my-4 w-full rounded-md text-clip">
<div className="relative">
<Carousel opts={{ loop: true, align: "start" }}>
<CarouselContent>
{immagini &&
immagini.map((img, idx) => (
<CarouselItem
key={`${img}-${idx}-cF`}
className={cn(
"group relative",
immagini && immagini.length === 1
? ""
: !single && "md:basis-1/2 lg:basis-1/3",
)}
>
<ImageFlbk
priority={idx === 0}
alt={img}
width={800}
height={400}
onClick={() => handleOpenModal(idx)}
src={img}
className={cn(
"h-72 w-full rounded-md object-cover",
immagini.length === 1 && "object-contain",
)}
/>
<Maximize2
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"
/>
</CarouselItem>
))}
</CarouselContent>
return (
<>
<div className="mx-auto my-4 w-full rounded-md text-clip">
<div className="relative">
<Carousel opts={{ loop: true, align: "start" }}>
<CarouselContent>
{immagini?.map((img, idx) => (
<CarouselItem
key={`${img}-${idx}-cF`}
className={cn(
"group relative",
immagini && immagini.length === 1
? ""
: !single && "md:basis-1/2 lg:basis-1/3",
)}
>
<ImageFlbk
priority={idx === 0}
alt={img}
width={800}
height={400}
onClick={() => handleOpenModal(idx)}
src={img}
className={cn(
"h-72 w-full rounded-md object-cover",
immagini.length === 1 && "object-contain",
)}
/>
<Maximize2
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"
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious
type="button"
className="left-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
<CarouselNext
type="button"
className="right-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
</Carousel>
</div>
</div>
<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">
<DialogHeader className="absolute top-0 right-0 left-0 z-10">
<DialogTitle className="sr-only">Image Dialog</DialogTitle>
<DialogDescription className="sr-only">Immagine</DialogDescription>
</DialogHeader>
<Carousel opts={{ loop: true, startIndex: idxModal }}>
<CarouselContent>
{immagini &&
immagini.map((img, idx) => (
<CarouselItem key={`${img}-${idx}-cD`}>
<Image
alt={`carousel-img-${idx}`}
width={1920}
height={1080}
src={img}
className="mx-auto h-[90vh] w-full rounded-md object-contain sm:w-[80vw]"
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious
type="button"
className="left-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
<CarouselNext
type="button"
className="right-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
</Carousel>
</div>
</div>
<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">
<DialogHeader className="absolute top-0 right-0 left-0 z-10">
<DialogTitle className="sr-only">Image Dialog</DialogTitle>
<DialogDescription className="sr-only">Immagine</DialogDescription>
</DialogHeader>
<Carousel opts={{ loop: true, startIndex: idxModal }}>
<CarouselContent>
{immagini?.map((img, idx) => (
<CarouselItem key={`${img}-${idx}-cD`}>
<Image
alt={`carousel-img-${idx}`}
width={1920}
height={1080}
src={img}
className="mx-auto h-[90vh] w-full rounded-md object-contain sm:w-[80vw]"
/>
</CarouselItem>
))}
</CarouselContent>
<CarouselPrevious
type="button"
className="left-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
<CarouselNext
type="button"
className="right-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
</Carousel>
</DialogContent>
</Dialog>
</>
);
<CarouselPrevious
type="button"
className="left-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
<CarouselNext
type="button"
className="right-2 cursor-pointer opacity-60 disabled:opacity-0"
disabled={!immagini || immagini.length === 1}
/>
</Carousel>
</DialogContent>
</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 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 {
Storageindex,
StorageindexId,
Storageindex,
StorageindexId,
} from "~/schemas/public/Storageindex";
import type { UsersId } from "~/schemas/public/Users";
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 = ({
userId,
isAdmin,
userId,
isAdmin,
}: {
userId: UsersId;
isAdmin: boolean;
userId: UsersId;
isAdmin: boolean;
}) => {
const { t } = useTranslation();
const { data: allegati, isLoading: isLoadingAllegati } =
api.storage.getUserStorage.useQuery({ userId: userId });
const { t } = useTranslation();
const { data: allegati, isLoading: isLoadingAllegati } =
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({
onMutate: () => {
const toastId = toast.loading(t.caricamento, {
icon: "🪛",
});
return { toastId };
},
onSuccess: async (_error, _variables, context) => {
await utils.storage.getUserStorage.invalidate({
userId: userId,
});
toast.success(t.allegati.allegato_rimosso, {
icon: "🗑️",
id: context?.toastId,
});
},
});
const { mutate: deleteFile } = api.storage.deleteUserStorage.useMutation({
onMutate: () => {
const toastId = toast.loading(t.caricamento, {
icon: "🪛",
});
return { toastId };
},
onSuccess: async (_error, _variables, context) => {
await utils.storage.getUserStorage.invalidate({
userId: userId,
});
toast.success(t.allegati.allegato_rimosso, {
icon: "🗑️",
id: context?.toastId,
});
},
});
const { mutate: renameFile } = api.storage.renameFile.useMutation({
onMutate: () => {
setEditOpen(false);
const toastId = toast.loading(t.caricamento, {
icon: "🪛",
});
return { toastId };
},
onSuccess: async (_error, _variables, context) => {
await utils.storage.getUserStorage.invalidate({
userId: userId,
});
toast.success(t.successo, {
icon: "👍",
id: context?.toastId,
});
},
});
const [editData, setEditData] = useState<{
id: StorageindexId;
filename: string;
} | null>(null);
const [editOpen, setEditOpen] = useState(false);
const { mutate: renameFile } = api.storage.renameFile.useMutation({
onMutate: () => {
setEditOpen(false);
const toastId = toast.loading(t.caricamento, {
icon: "🪛",
});
return { toastId };
},
onSuccess: async (_error, _variables, context) => {
await utils.storage.getUserStorage.invalidate({
userId: userId,
});
toast.success(t.successo, {
icon: "👍",
id: context?.toastId,
});
},
});
const [editData, setEditData] = useState<{
id: StorageindexId;
filename: string;
} | null>(null);
const [editOpen, setEditOpen] = useState(false);
const handleEditSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
if (!formData.get("id") || !formData.get("filename")) {
return;
}
renameFile({
fileId: formData.get("id") as StorageindexId,
name: formData.get("filename") as string,
});
};
const handleEditSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
if (!formData.get("id") || !formData.get("filename")) {
return;
}
renameFile({
fileId: formData.get("id") as StorageindexId,
name: formData.get("filename") as string,
});
};
const utils = api.useUtils();
const utils = api.useUtils();
if (isLoadingAllegati) return <LoadingPage />;
if (!allegati) return <Status500 />;
if (isLoadingAllegati) return <LoadingPage />;
if (!allegati) return <Status500 />;
const from_admin = allegati.filter((a) => a.from_admin);
const not_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);
return (
<div className="flex flex-col space-y-5">
<h1 className="text-primary text-2xl font-bold">{t.allegati.title}</h1>
<h1 className="text-xl font-semibold">{t.allegati.da_info}</h1>
{from_admin && (
<FileList
files={from_admin}
isAdmin={isAdmin}
selectHandler={(id, filename) => {
setEditData({ id, filename });
setEditOpen(true);
}}
delete_onClick={(id) => deleteFile({ userId, fileId: id })}
download_onClick={(file) =>
handleDownload({
file,
getToken: async () => await getToken(),
})
}
/>
)}
<Separator />
<div className="flex items-center gap-3">
<UploadModal userId={userId} isAdmin={isAdmin} />{" "}
<span className="text-sm">Max 5 MB per file</span>
</div>
<div className="space-y-2 rounded-md border border-orange-500 p-2">
<div className="flex items-center gap-2 text-orange-500">
<TriangleAlert className="size-5" />
{t.importante}:
</div>
<span className="text-sm">{t.allegati.disclamer}</span>
</div>
return (
<div className="flex flex-col space-y-5">
<h1 className="text-primary text-2xl font-bold">{t.allegati.title}</h1>
<h1 className="text-xl font-semibold">{t.allegati.da_info}</h1>
{from_admin && (
<FileList
files={from_admin}
isAdmin={isAdmin}
selectHandler={(id, filename) => {
setEditData({ id, filename });
setEditOpen(true);
}}
delete_onClick={(id) => deleteFile({ userId, fileId: id })}
download_onClick={(file) =>
handleDownload({
file,
getToken: async () => await getToken(),
})
}
/>
)}
<Separator />
<div className="flex items-center gap-3">
<UploadModal userId={userId} isAdmin={isAdmin} />{" "}
<span className="text-sm">Max 5 MB per file</span>
</div>
<div className="space-y-2 rounded-md border border-orange-500 p-2">
<div className="flex items-center gap-2 text-orange-500">
<TriangleAlert className="size-5" />
{t.importante}:
</div>
<span className="text-sm">{t.allegati.disclamer}</span>
</div>
{not_from_admin && (
<FileList
files={not_from_admin}
isAdmin={isAdmin}
selectHandler={(id, filename) => {
setEditData({ id, filename });
setEditOpen(true);
}}
delete_onClick={(id) => deleteFile({ userId, fileId: id })}
download_onClick={(file) =>
handleDownload({
file,
getToken: async () => await getToken(),
})
}
/>
)}
<EditFileDialog
open={editOpen}
onOpenChange={setEditOpen}
data={editData}
handleEditSubmit={handleEditSubmit}
/>
</div>
);
{not_from_admin && (
<FileList
files={not_from_admin}
isAdmin={isAdmin}
selectHandler={(id, filename) => {
setEditData({ id, filename });
setEditOpen(true);
}}
delete_onClick={(id) => deleteFile({ userId, fileId: id })}
download_onClick={(file) =>
handleDownload({
file,
getToken: async () => await getToken(),
})
}
/>
)}
<EditFileDialog
open={editOpen}
onOpenChange={setEditOpen}
data={editData}
handleEditSubmit={handleEditSubmit}
/>
</div>
);
};
export const ExtIcon = ({
ext,
className,
ext,
className,
}: {
ext: string | null | undefined;
className?: string;
ext: string | null | undefined;
className?: string;
}) => {
if (!ext) return <File className={cn("size-5 text-gray-500", className)} />;
if (ext.includes("pdf")) {
return <FileText className={cn("size-5 text-red-500", className)} />;
} else if (
ext.includes("image") ||
["jpg", "jpeg", "png", "gif", "svg", "webp"].some((ext) =>
ext.includes(ext),
)
) {
return <ImageIcon className={cn("size-5 text-blue-500", className)} />;
} else if (
["html", "css", "js", "jsx", "ts", "tsx", "json", "xml"].some((ext) =>
ext.includes(ext),
)
) {
return <FileCode className={cn("size-5 text-emerald-500", className)} />;
} else if (["xls", "xlsx", "csv"].some((ext) => ext.includes(ext))) {
return (
<FileSpreadsheet className={cn("size-5 text-green-600", className)} />
);
} else if (
["zip", "rar", "7z", "tar", "gz"].some((ext) => ext.includes(ext))
) {
return <FileArchive className={cn("size-5 text-amber-600", className)} />;
} else if (["mp3", "wav", "ogg", "flac"].some((ext) => ext.includes(ext))) {
return <FileAudio className={cn("size-5 text-purple-500", className)} />;
} else if (
["mp4", "avi", "mov", "wmv", "webm"].some((ext) => ext.includes(ext))
) {
return <FileVideo className={cn("size-5 text-pink-500", className)} />;
} else {
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")) {
return <FileText className={cn("size-5 text-red-500", className)} />;
} else if (
ext.includes("image") ||
["jpg", "jpeg", "png", "gif", "svg", "webp"].some((ext) =>
ext.includes(ext),
)
) {
return <ImageIcon className={cn("size-5 text-blue-500", className)} />;
} else if (
["html", "css", "js", "jsx", "ts", "tsx", "json", "xml"].some((ext) =>
ext.includes(ext),
)
) {
return <FileCode className={cn("size-5 text-emerald-500", className)} />;
} else if (["xls", "xlsx", "csv"].some((ext) => ext.includes(ext))) {
return (
<FileSpreadsheet className={cn("size-5 text-green-600", className)} />
);
} else if (
["zip", "rar", "7z", "tar", "gz"].some((ext) => ext.includes(ext))
) {
return <FileArchive className={cn("size-5 text-amber-600", className)} />;
} else if (["mp3", "wav", "ogg", "flac"].some((ext) => ext.includes(ext))) {
return <FileAudio className={cn("size-5 text-purple-500", className)} />;
} else if (
["mp4", "avi", "mov", "wmv", "webm"].some((ext) => ext.includes(ext))
) {
return <FileVideo className={cn("size-5 text-pink-500", className)} />;
} else {
return <File className={cn("size-5 text-gray-500", className)} />;
}
};
const EditFileDialog = ({
open,
onOpenChange,
data,
handleEditSubmit,
open,
onOpenChange,
data,
handleEditSubmit,
}: {
open: boolean;
onOpenChange: (status: boolean) => void;
data: {
id: StorageindexId;
filename: string;
} | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
open: boolean;
onOpenChange: (status: boolean) => void;
data: {
id: StorageindexId;
filename: string;
} | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
}) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader>
{data && (
<form onSubmit={handleEditSubmit} method="post">
<div className="flex items-center gap-3">
<label htmlFor="id" className="sr-only">
ID
</label>
<Input
type="text"
className="hidden"
name="id"
value={data.id}
readOnly
/>
<label htmlFor="filename" className="sr-only">
filename
</label>
<Input
type="text"
placeholder="Modifica messaggio"
className="mt-0"
id="filename"
name="filename"
defaultValue={data.filename || undefined}
/>
<Button type="submit">{t.salva}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader>
{data && (
<form onSubmit={handleEditSubmit} method="post">
<div className="flex items-center gap-3">
<label htmlFor="id" className="sr-only">
ID
</label>
<Input
type="text"
className="hidden"
name="id"
value={data.id}
readOnly
/>
<label htmlFor="filename" className="sr-only">
filename
</label>
<Input
type="text"
placeholder="Modifica messaggio"
className="mt-0"
id="filename"
name="filename"
defaultValue={data.filename || undefined}
/>
<Button type="submit">{t.salva}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
};
interface FileListProps {
files: Storageindex[];
delete_onClick: (id: StorageindexId) => void;
download_onClick: (file: Storageindex) => void;
isAdmin: boolean;
selectHandler: (id: StorageindexId, filename: string) => void;
files: Storageindex[];
delete_onClick: (id: StorageindexId) => void;
download_onClick: (file: Storageindex) => void;
isAdmin: boolean;
selectHandler: (id: StorageindexId, filename: string) => void;
}
function FileList({
files,
delete_onClick,
download_onClick,
isAdmin,
selectHandler,
files,
delete_onClick,
download_onClick,
isAdmin,
selectHandler,
}: FileListProps) {
return (
<div className="w-full">
<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="col-span-6 md:col-span-8">File</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>
<div className="divide-y">
{files.map((file) => (
<div
key={file.id}
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="shrink-0">
<ExtIcon ext={file.ext} />
</div>
<Link
aria-label="Allegato"
href={`/area-riservata/allegato-view/${file.id}`}
target="_blank"
className="text-foreground truncate font-medium hover:underline"
>
{file.filename}
</Link>
</div>
return (
<div className="w-full">
<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="col-span-6 md:col-span-8">File</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>
<div className="divide-y">
{files.map((file) => (
<div
key={file.id}
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="shrink-0">
<ExtIcon ext={file.ext} />
</div>
<Link
aria-label="Allegato"
href={`/area-riservata/allegato-view/${file.id}`}
target="_blank"
className="text-foreground truncate font-medium hover:underline"
>
{file.filename}
</Link>
</div>
<div className="text-muted-foreground col-span-4 md:col-span-3">
{format(file.created_at, "d MMM yyyy")}
</div>
<div className="col-span-2 flex justify-end gap-1 md:col-span-1">
<Button
aria-label="Download"
variant="ghost"
size="icon"
asChild
className="size-8"
title="Download"
>
<Button
variant="outline"
className="flex gap-1 text-sm"
onClick={() => download_onClick(file)}
>
<Download className="size-4" />
</Button>
</Button>
<div className="text-muted-foreground col-span-4 md:col-span-3">
{format(file.created_at, "d MMM yyyy")}
</div>
<div className="col-span-2 flex justify-end gap-1 md:col-span-1">
<Button
aria-label="Download"
variant="ghost"
size="icon"
asChild
className="size-8"
title="Download"
>
<Button
variant="outline"
className="flex gap-1 text-sm"
onClick={() => download_onClick(file)}
>
<Download className="size-4" />
</Button>
</Button>
{isAdmin && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
aria-label="Rinomina"
onClick={() =>
selectHandler(file.id, file.filename || "")
}
>
<Pencil className="mr-2 size-4" />
Rinomina
</DropdownMenuItem>
<DropdownMenuItem
aria-label="Elimina"
onClick={() => delete_onClick(file.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
Elimina
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
{isAdmin && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
<span className="sr-only">More options</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
aria-label="Rinomina"
onClick={() =>
selectHandler(file.id, file.filename || "")
}
>
<Pencil className="mr-2 size-4" />
Rinomina
</DropdownMenuItem>
<DropdownMenuItem
aria-label="Elimina"
onClick={() => delete_onClick(file.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 size-4" />
Elimina
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}

View file

@ -1,63 +1,63 @@
import type { UsersId } from "~/schemas/public/Users";
import { api } from "~/utils/api";
import { useState } from "react";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "~/components/ui/accordion";
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 }) => {
const { data } = api.comunicazioni.getEmails.useQuery({
userId,
});
const { data } = api.comunicazioni.getEmails.useQuery({
userId,
});
// Track which accordion item is open
const [openIndex, setOpenIndex] = useState<string | undefined>(undefined);
// Track which accordion item is open
const [openIndex, setOpenIndex] = useState<string | undefined>(undefined);
const mobile = useMediaQuery("(max-width: 768px)");
if (!data || data.length === 0) {
return <div className="text-center">Nessuna comunicazione trovata</div>;
}
const mobile = useMediaQuery("(max-width: 768px)");
if (!data || data.length === 0) {
return <div className="text-center">Nessuna comunicazione trovata</div>;
}
return (
<div className="mx-auto py-4">
<Accordion
type="single"
collapsible
className="space-y-4"
value={openIndex}
onValueChange={setOpenIndex}
>
{data.map((email, index) => (
<AccordionItem
value={String(index)}
key={index}
className="space-y-2 border-none"
>
<AccordionTrigger className="rounded-md bg-muted px-4 py-2">
Email &ldquo;{email.title}&rdquo; del{" "}
{new Date(email.created_at).toLocaleDateString("it-IT", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})}
</AccordionTrigger>
<AccordionContent>
{openIndex === String(index) && (
<iframe
title="Email Content"
srcDoc={email.html}
height={mobile ? "600px" : "500px"}
width="100%"
/>
)}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
);
return (
<div className="mx-auto py-4">
<Accordion
type="single"
collapsible
className="space-y-4"
value={openIndex}
onValueChange={setOpenIndex}
>
{data.map((email, index) => (
<AccordionItem
value={String(index)}
key={index}
className="space-y-2 border-none"
>
<AccordionTrigger className="rounded-md bg-muted px-4 py-2">
Email &ldquo;{email.title}&rdquo; del{" "}
{new Date(email.created_at).toLocaleDateString("it-IT", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})}
</AccordionTrigger>
<AccordionContent>
{openIndex === String(index) && (
<iframe
title="Email Content"
srcDoc={email.html}
height={mobile ? "600px" : "500px"}
width="100%"
/>
)}
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</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 { useTranslation } from "~/providers/I18nProvider";
import TimeserieBarChart from "~/components/timeserieBarChart";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import TimeserieBarChartStacked from "~/components/timeserieBarChartStacked";
import { LoadingPage } from "~/components/loading";
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 = () => {
const { data: stats, isLoading } = api.stats.adminDashStats.useQuery();
const { data: stats, isLoading } = api.stats.adminDashStats.useQuery();
if (isLoading) return <LoadingPage />;
return (
<div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto pt-4 pb-8">
<div className="mx-3 items-start justify-between pb-3 md:flex">
<div className="flex max-w-lg items-center gap-3">
<h3 className="text-2xl font-bold">Admin Dashboard</h3>
</div>
</div>
{!stats ? (
<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">
<Card className="">
<CardHeader>
<CardTitle>Utenti</CardTitle>
<CardDescription>
Numero totale di utenti registrati
</CardDescription>
</CardHeader>
if (isLoading) return <LoadingPage />;
return (
<div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto pt-4 pb-8">
<div className="mx-3 items-start justify-between pb-3 md:flex">
<div className="flex max-w-lg items-center gap-3">
<h3 className="text-2xl font-bold">Admin Dashboard</h3>
</div>
</div>
{!stats ? (
<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">
<Card className="">
<CardHeader>
<CardTitle>Utenti</CardTitle>
<CardDescription>
Numero totale di utenti registrati
</CardDescription>
</CardHeader>
<CardContent className="flex h-full items-center justify-center text-6xl font-bold">
{stats.userCount}
</CardContent>
</Card>
<TimeserieBarChart
title="Utenti registrati"
timeserie={stats.userTimeserie}
/>
<CardContent className="flex h-full items-center justify-center text-6xl font-bold">
{stats.userCount}
</CardContent>
</Card>
<TimeserieBarChart
title="Utenti registrati"
timeserie={stats.userTimeserie}
/>
<TimeserieBarChartStacked
title="Servizi creati"
valueALabel={stats.serviziTimeserie.valueALabel}
valueBLabel={stats.serviziTimeserie.valueBLabel}
timeserie={stats.serviziTimeserie.data}
/>
<TimeserieBarChartStacked
title="Servizi creati"
valueALabel={stats.serviziTimeserie.valueALabel}
valueBLabel={stats.serviziTimeserie.valueBLabel}
timeserie={stats.serviziTimeserie.data}
/>
<TimeserieBarChart
title="Contatti generati"
timeserie={stats.contattiTimeserie}
/>
</div>
)}
</div>
</div>
);
<TimeserieBarChart
title="Contatti generati"
timeserie={stats.contattiTimeserie}
/>
</div>
)}
</div>
</div>
);
};
export const UserDashboard = ({ userId }: { userId: UsersId }) => {
// const { data: servizi, isLoading: loadingServizi } =
// api.servizio.getAllServizioAnnunci.useQuery({ userId });
const { t } = useTranslation();
return (
<div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto pt-4">
<div className="mx-3 items-start justify-between md:flex">
<div className="flex max-w-lg items-center gap-3">
<h3 className="text-2xl font-bold">Le tue ricerche</h3>
</div>
</div>
// const { data: servizi, isLoading: loadingServizi } =
// api.servizio.getAllServizioAnnunci.useQuery({ userId });
const { t } = useTranslation();
return (
<div className="mx-1 flex-1 overflow-auto">
<div className="mx-auto pt-4">
<div className="mx-3 items-start justify-between md:flex">
<div className="flex max-w-lg items-center gap-3">
<h3 className="text-2xl font-bold">Le tue ricerche</h3>
</div>
</div>
<div className="flex flex-col space-y-5 px-2">
<TabRicerca userId={userId} isAdmin={false} />
{/* {loadingServizi ? (
<div className="flex flex-col space-y-5 px-2">
<TabRicerca userId={userId} isAdmin={false} />
{/* {loadingServizi ? (
<LoadingPage />
) : (
servizi?.map((servizio, idx) => (
@ -92,12 +92,12 @@ export const UserDashboard = ({ userId }: { userId: UsersId }) => {
/>
))
)} */}
</div>
<AccordionComp
texts={t.area_riservata.dash_accordion_minifaq}
className="w-full max-w-full px-2 pt-4"
/>
</div>
</div>
);
</div>
<AccordionComp
texts={t.area_riservata.dash_accordion_minifaq}
className="w-full max-w-full px-2 pt-4"
/>
</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 {
Credenza,
CredenzaContent,
CredenzaHeader,
CredenzaTitle,
CredenzaDescription,
CredenzaBody,
CredenzaFooter,
CredenzaClose,
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
} from "~/components/custom_ui/credenza";
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 { 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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import PaymentStatusEnum from "~/schemas/public/PaymentStatusEnum";
import Link from "next/link";
import { cn } from "~/lib/utils";
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 = ({
isAdmin,
open,
setModalOpen,
ordineId,
userId,
isAdmin,
open,
setModalOpen,
ordineId,
userId,
}: {
isAdmin: boolean;
open: boolean;
setModalOpen: (open: boolean) => void;
ordineId: OrdiniOrdineId;
userId: UsersId | undefined;
isAdmin: boolean;
open: boolean;
setModalOpen: (open: boolean) => void;
ordineId: OrdiniOrdineId;
userId: UsersId | undefined;
}) => {
const utils = api.useUtils();
const { mutate: remove } = api.servizio.removeOrder.useMutation({
onSuccess: async () => {
toast.success("Ordine rimosso con successo!");
await utils.servizio.getOrdini.invalidate({
userId,
});
setModalOpen(false);
},
onError: (error) => {
toast.error(`Errore durante la rimozione ordine: ${error.message}`);
},
});
const { mutate: update } = api.servizio.updateOrder.useMutation({
onSuccess: async (data) => {
if (data.type === OrderTypeEnum.Acconto && data.isActive) {
updateServizio({
servizioId: data.servizio_id,
data: {
isOkAcconto: true,
decorrenza: new Date(),
},
});
}
toast.success("Ordine aggiornato con successo!");
await utils.servizio.getOrdini.invalidate({
userId,
});
await utils.servizio.getOrdineById.invalidate({
ordineId,
});
},
onError: (error) => {
toast.error(`Errore durante la modifica: ${error.message}`);
},
});
const utils = api.useUtils();
const { mutate: remove } = api.servizio.removeOrder.useMutation({
onSuccess: async () => {
toast.success("Ordine rimosso con successo!");
await utils.servizio.getOrdini.invalidate({
userId,
});
setModalOpen(false);
},
onError: (error) => {
toast.error(`Errore durante la rimozione ordine: ${error.message}`);
},
});
const { mutate: update } = api.servizio.updateOrder.useMutation({
onSuccess: async (data) => {
if (data.type === OrderTypeEnum.Acconto && data.isActive) {
updateServizio({
servizioId: data.servizio_id,
data: {
isOkAcconto: true,
decorrenza: new Date(),
},
});
}
toast.success("Ordine aggiornato con successo!");
await utils.servizio.getOrdini.invalidate({
userId,
});
await utils.servizio.getOrdineById.invalidate({
ordineId,
});
},
onError: (error) => {
toast.error(`Errore durante la modifica: ${error.message}`);
},
});
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate();
},
onError: (error) => {
toast.error(error.message);
},
});
const { mutate: updateServizio } = api.servizio.updateServizio.useMutation({
onSuccess: async () => {
toast.success("Servizio modificato con successo");
await utils.servizio.getUserServizi.invalidate();
},
onError: (error) => {
toast.error(error.message);
},
});
const { data } = api.servizio.getOrdineById.useQuery({
ordineId,
});
if (!data) return null;
const { data } = api.servizio.getOrdineById.useQuery({
ordineId,
});
if (!data) return null;
return (
<Credenza
open={open}
onOpenChange={(b) => {
setModalOpen(b);
}}
>
<CredenzaContent className="max-h-[90vh] max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>Dettagli Ordine</CredenzaTitle>
<CredenzaDescription>
Visualizza i dettagli e i pagamenti associati.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
<p>Id Ordine: {data.ordine_id}</p>
<p>
Data Ordine:
{new Date(data.created_at).toLocaleDateString("it-IT", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})}
</p>
<p>Tipologia: {data.type.toString()}</p>
<p>Pack ID: {data.packid}</p>
<p className="flex items-center gap-2">
Stato:{" "}
{data.isActive ? (
<span className="inline-flex items-center gap-1 text-green-500">
<CircleCheck className="size-5" />
Attivo
</span>
) : (
<span className="inline-flex items-center gap-1">
<CircleSlash className="size-5" />
Non Attivo
</span>
)}
</p>
return (
<Credenza
open={open}
onOpenChange={(b) => {
setModalOpen(b);
}}
>
<CredenzaContent className="max-h-[90vh] max-w-2xl">
<CredenzaHeader>
<CredenzaTitle>Dettagli Ordine</CredenzaTitle>
<CredenzaDescription>
Visualizza i dettagli e i pagamenti associati.
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-2 overflow-auto pb-5">
<p>Id Ordine: {data.ordine_id}</p>
<p>
Data Ordine:
{new Date(data.created_at).toLocaleDateString("it-IT", {
year: "numeric",
month: "2-digit",
day: "2-digit",
})}
</p>
<p>Tipologia: {data.type.toString()}</p>
<p>Pack ID: {data.packid}</p>
<p className="flex items-center gap-2">
Stato:{" "}
{data.isActive ? (
<span className="inline-flex items-center gap-1 text-green-500">
<CircleCheck className="size-5" />
Attivo
</span>
) : (
<span className="inline-flex items-center gap-1">
<CircleSlash className="size-5" />
Non Attivo
</span>
)}
</p>
{data.isActive &&
data.pagamenti.find(
(p) => p.paymentstatus === PaymentStatusEnum.success,
) && (
<div>
<Link
href={`/area-riservata/payment-recap/${data.ordine_id}`}
target="_blank"
>
<Button variant="outline" className="flex items-center gap-2">
Ricevuta di cortesia <ReceiptText />
</Button>
</Link>
</div>
)}
{data.isActive &&
data.pagamenti.find(
(p) => p.paymentstatus === PaymentStatusEnum.success,
) && (
<div>
<Link
href={`/area-riservata/payment-recap/${data.ordine_id}`}
target="_blank"
>
<Button variant="outline" className="flex items-center gap-2">
Ricevuta di cortesia <ReceiptText />
</Button>
</Link>
</div>
)}
<PaymentsTable data={data.pagamenti} isAdmin={isAdmin} isUserView />
</CredenzaBody>
<CredenzaFooter
className={cn(
"flex w-full",
isAdmin ? "sm:justify-between" : "sm:justify-end",
)}
>
{isAdmin && (
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" aria-label="Modifica Status">
Modifica Status
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={data.isActive ? "true" : "false"}
onValueChange={(v) => {
update({
ordineId,
data: { isActive: v === "true" },
});
}}
>
<DropdownMenuRadioItem value="true">
Attivo
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="false">
Non Attivo
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<PaymentsTable data={data.pagamenti} isAdmin={isAdmin} isUserView />
</CredenzaBody>
<CredenzaFooter
className={cn(
"flex w-full",
isAdmin ? "sm:justify-between" : "sm:justify-end",
)}
>
{isAdmin && (
<div className="flex items-center gap-4">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" aria-label="Modifica Status">
Modifica Status
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuLabel>Status</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={data.isActive ? "true" : "false"}
onValueChange={(v) => {
update({
ordineId,
data: { isActive: v === "true" },
});
}}
>
<DropdownMenuRadioItem value="true">
Attivo
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="false">
Non Attivo
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
<Confirm
title="Elimina ordine"
description="Sei sicuro di voler eliminare l'ordine?"
onConfirm={() => {
remove({ ordineId });
}}
>
<Button variant="destructive" aria-label="Elimina Ordine">
<Trash2 className="size-6" />
Elimina Ordine
</Button>
</Confirm>
</div>
)}
<CredenzaClose asChild>
<Button variant="ghost">Chiudi</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
<Confirm
title="Elimina ordine"
description="Sei sicuro di voler eliminare l'ordine?"
onConfirm={() => {
remove({ ordineId });
}}
>
<Button variant="destructive" aria-label="Elimina Ordine">
<Trash2 className="size-6" />
Elimina Ordine
</Button>
</Confirm>
</div>
)}
<CredenzaClose asChild>
<Button variant="ghost">Chiudi</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</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 { useEffect, useState } from "react";
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 = ({
isAdmin,
className,
isAdmin,
className,
}: {
isAdmin: boolean;
className?: string;
isAdmin: boolean;
className?: string;
}) => {
const pathname = usePathname();
const pathname = usePathname();
const [minimized, setMinimized] = useState(false);
const [minimized, setMinimized] = useState(false);
useEffect(() => {
const defaultOpen = getCookie("sidebar_minimized") === "true";
setMinimized(defaultOpen);
}, []);
const toggleSidebar = (v: boolean) => {
setMinimized(v);
setCookie("sidebar_minimized", v.toString(), {
expires: add(new Date(), { days: 30 }),
});
};
useEffect(() => {
const defaultOpen = getCookie("sidebar_minimized") === "true";
setMinimized(defaultOpen);
}, []);
const toggleSidebar = (v: boolean) => {
setMinimized(v);
setCookie("sidebar_minimized", v.toString(), {
expires: add(new Date(), { days: 30 }),
});
};
return (
<motion.nav
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",
className,
minimized && "cursor-pointer",
//defaultOpen && "w-[65px]",
)}
animate={{ width: minimized ? 65 : 192 }}
onClick={
minimized
? (e) => {
e.preventDefault();
e.stopPropagation();
toggleSidebar(false);
}
: undefined
}
aria-label="Sidebar Menu"
>
<div
className={cn(
"flex items-center justify-between px-7",
minimized && "justify-center px-0",
)}
>
{!minimized && (
<h4 className={cn("w-full text-xl font-semibold")}>Menu</h4>
)}
<Button
aria-label="Toggle Sidebar"
variant="ghost"
onClick={() => toggleSidebar(!minimized)}
className="px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
>
{minimized ? (
<Expand className="size-6" />
) : (
<Minimize2 className="size-6" />
)}
<span className="sr-only">Toggle Menu</span>
</Button>
</div>
<div className={cn("px-4 text-lg", minimized && "px-0")}>
<ARMinimizableLinks
isAdmin={isAdmin}
setOpen={toggleSidebar}
pathname={pathname}
minimized={minimized}
/>
</div>
</motion.nav>
);
return (
<motion.nav
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",
className,
minimized && "cursor-pointer",
//defaultOpen && "w-[65px]",
)}
animate={{ width: minimized ? 65 : 192 }}
onClick={
minimized
? (e) => {
e.preventDefault();
e.stopPropagation();
toggleSidebar(false);
}
: undefined
}
aria-label="Sidebar Menu"
>
<div
className={cn(
"flex items-center justify-between px-7",
minimized && "justify-center px-0",
)}
>
{!minimized && (
<h4 className={cn("w-full text-xl font-semibold")}>Menu</h4>
)}
<Button
aria-label="Toggle Sidebar"
variant="ghost"
onClick={() => toggleSidebar(!minimized)}
className="px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0"
>
{minimized ? (
<Expand className="size-6" />
) : (
<Minimize2 className="size-6" />
)}
<span className="sr-only">Toggle Menu</span>
</Button>
</div>
<div className={cn("px-4 text-lg", minimized && "px-0")}>
<ARMinimizableLinks
isAdmin={isAdmin}
setOpen={toggleSidebar}
pathname={pathname}
minimized={minimized}
/>
</div>
</motion.nav>
);
};

View file

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

View file

@ -1,5 +1,5 @@
export default function BlurryDivider() {
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" />
);
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" />
);
}

View file

@ -1,191 +1,191 @@
"use client";
import { differenceInDays, differenceInMinutes, format } from "date-fns";
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 { cn } from "~/lib/utils";
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 { ChatUserInfo } from "~/server/services/chat.service";
import { UploadComponent } from "~/components/upload_modal";
import { useTranslation } from "~/providers/I18nProvider";
import { differenceInDays, differenceInMinutes, format } from "date-fns";
import { api } from "~/utils/api";
type ChatAttachmentsProps = {
userData: Session;
chatUserData: ChatUserInfo;
userData: Session;
chatUserData: ChatUserInfo;
};
export const ChatAttachments = ({
userData,
chatUserData,
userData,
chatUserData,
}: ChatAttachmentsProps) => {
const { t } = useTranslation();
const {
data: attachments,
isLoading: attachmentsLoading,
refetch,
} = api.storage.getUserStorage.useQuery(
{ userId: chatUserData.id },
{ refetchOnMount: true },
);
const { t } = useTranslation();
const {
data: attachments,
isLoading: attachmentsLoading,
refetch,
} = api.storage.getUserStorage.useQuery(
{ userId: chatUserData.id },
{ refetchOnMount: true },
);
return (
<Credenza onOpenChange={(o) => o && refetch()}>
<CredenzaTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative size-9"
aria-label="Allegati Chat"
>
{attachments && attachments.length > 0 && (
<span className="absolute -top-1 right-0 rounded-full bg-blue-500 px-1 text-xs font-bold text-white">
{attachments.length}
</span>
)}
<Paperclip size={20} className="text-muted-foreground" />
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]">
<CredenzaHeader>
<CredenzaTitle className="sr-only">Allegati</CredenzaTitle>
<CredenzaDescription className="sr-only">
Chat Attachments
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-3 overflow-auto pb-5">
<UploadComponent
isAdmin={userData.isAdmin}
userId={chatUserData.id}
/>
return (
<Credenza onOpenChange={(o) => o && refetch()}>
<CredenzaTrigger asChild>
<Button
variant="ghost"
size="icon"
className="relative size-9"
aria-label="Allegati Chat"
>
{attachments && attachments.length > 0 && (
<span className="absolute -top-1 right-0 rounded-full bg-blue-500 px-1 text-xs font-bold text-white">
{attachments.length}
</span>
)}
<Paperclip size={20} className="text-muted-foreground" />
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh]">
<CredenzaHeader>
<CredenzaTitle className="sr-only">Allegati</CredenzaTitle>
<CredenzaDescription className="sr-only">
Chat Attachments
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="max-h-[80vh] space-y-3 overflow-auto pb-5">
<UploadComponent
isAdmin={userData.isAdmin}
userId={chatUserData.id}
/>
<div className="h-auto max-h-[40rem] max-w-lg overflow-auto rounded-lg border-2 border-gray-300 py-4">
{attachmentsLoading ? (
<LoadingPage />
) : (
<>
{!attachments || attachments.length == 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-center text-lg font-semibold text-gray-500">
{t.allegati.nessun_allegato}
</p>
</div>
) : (
<ul className="px-2">
<h3 className="px-3 text-lg font-semibold text-gray-700">
{t.allegati.allegati_recenti}
</h3>
{attachments?.map((file, idx) => {
if (idx >= 3) return null;
<div className="h-auto max-h-[40rem] max-w-lg overflow-auto rounded-lg border-2 border-gray-300 py-4">
{attachmentsLoading ? (
<LoadingPage />
) : (
<>
{!attachments || attachments.length === 0 ? (
<div className="flex h-full items-center justify-center">
<p className="text-center text-lg font-semibold text-gray-500">
{t.allegati.nessun_allegato}
</p>
</div>
) : (
<ul className="px-2">
<h3 className="px-3 text-lg font-semibold text-gray-700">
{t.allegati.allegati_recenti}
</h3>
{attachments?.map((file, idx) => {
if (idx >= 3) return null;
return (
<li
key={`${file.filename}-${idx}`}
className="flex items-center justify-between border-b px-3 py-1"
>
<Link
aria-label="Visualizza Allegato"
href={`/area-riservata/allegato-view/${file.id}`}
target="_blank"
className={cn(
buttonVariants({
variant: "link",
}),
"flex h-8 items-center gap-2 truncate p-0 text-base",
)}
>
{file.ext && <ExtIcon ext={file.ext} />}
<span className="truncate">{file.filename}</span>
</Link>
<span
className="text-muted-foreground text-xs"
key={new Date().getTime()}
>
{TimeSince(file.created_at)}
</span>
</li>
);
})}
</ul>
)}
</>
)}
</div>
</CredenzaBody>
<CredenzaFooter className="flex flex-row items-center justify-between gap-2">
<Link
aria-label="Visualizza Allegati"
href="/area-riservata/allegati"
className={cn(
buttonVariants({
variant: "outline",
}),
"relative w-full",
)}
>
<Paperclip /> {t.allegati.i_tuoi_allegati}
{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">
{attachments.length}
</span>
)}
</Link>
<CredenzaClose asChild>
<Button className="w-full">{t.chiudi}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
return (
<li
key={`${file.filename}-${idx}`}
className="flex items-center justify-between border-b px-3 py-1"
>
<Link
aria-label="Visualizza Allegato"
href={`/area-riservata/allegato-view/${file.id}`}
target="_blank"
className={cn(
buttonVariants({
variant: "link",
}),
"flex h-8 items-center gap-2 truncate p-0 text-base",
)}
>
{file.ext && <ExtIcon ext={file.ext} />}
<span className="truncate">{file.filename}</span>
</Link>
<span
className="text-muted-foreground text-xs"
key={new Date().toString()}
>
{TimeSince(file.created_at)}
</span>
</li>
);
})}
</ul>
)}
</>
)}
</div>
</CredenzaBody>
<CredenzaFooter className="flex flex-row items-center justify-between gap-2">
<Link
aria-label="Visualizza Allegati"
href="/area-riservata/allegati"
className={cn(
buttonVariants({
variant: "outline",
}),
"relative w-full",
)}
>
<Paperclip /> {t.allegati.i_tuoi_allegati}
{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">
{attachments.length}
</span>
)}
</Link>
<CredenzaClose asChild>
<Button className="w-full">{t.chiudi}</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
};
const TimeSince = (date: Date) => {
const minsSince = Math.abs(differenceInMinutes(date, new Date()));
const daysSince = Math.abs(differenceInDays(date, new Date()));
const minsSince = Math.abs(differenceInMinutes(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 (minsSince < 1) {
return "ora";
}
if (daysSince === 0) {
if (minsSince < 1) {
return "ora";
}
const closestToNow = relevantMinsIncrements.reduce((prev, curr) => {
return Math.abs(curr - minsSince) < Math.abs(prev - minsSince)
? curr
: prev;
});
const closestToNow = relevantMinsIncrements.reduce((prev, curr) => {
return Math.abs(curr - minsSince) < Math.abs(prev - minsSince)
? curr
: prev;
});
if (closestToNow === 60) {
return "~1 ora fa";
}
if (closestToNow === 60) {
return "~1 ora fa";
}
if (closestToNow === 120 && minsSince < 121) {
return "~2 ore fa";
}
if (closestToNow === 120 && minsSince < 121) {
return "~2 ore fa";
}
return `~${closestToNow} min fa`;
}
return `~${closestToNow} min fa`;
}
if (daysSince === 1) {
return "ieri";
}
if (daysSince === 1) {
return "ieri";
}
if (daysSince < 30) {
return `${daysSince} giorni fa`;
}
if (daysSince < 30) {
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 {
useCallback,
useEffect,
useRef,
useState,
type ChangeEvent,
type ChangeEvent,
useCallback,
useEffect,
useRef,
useState,
} 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 {
AutosizeTextarea,
type AutosizeTextAreaRef,
} from "~/components/custom_ui/autoResizeTextArea";
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,
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import {
EmojiPickerContent,
EmojiPickerSearch,
EmojiPicker,
} from "~/components/custom_ui/emoji-picker";
import { useThrottledIsTypingMutation } from "~/hooks/chatHooks";
import { useMediaQuery } from "~/hooks/use-media-query";
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 = {
chatId: ChatsChatid;
userData: Session;
chatUserData: ChatUserInfo;
chatId: ChatsChatid;
userData: Session;
chatUserData: ChatUserInfo;
};
export default function ChatBottombar({
chatId,
userData,
chatUserData,
chatId,
userData,
chatUserData,
}: ChatBottombarProps) {
const { locale } = useTranslation();
const [message, setMessage] = useState("");
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<AutosizeTextAreaRef>(null);
const { locale } = useTranslation();
const [message, setMessage] = useState("");
const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<AutosizeTextAreaRef>(null);
const { mutate: addMutation } = api.messagesSSE.add.useMutation({});
const { mutate: addMutation } = api.messagesSSE.add.useMutation({});
const handleThumbsUp = useCallback(() => {
addMutation({
chatId,
message: "👍",
sender: userData.id,
});
setMessage("");
}, [chatId, addMutation, userData]);
const handleThumbsUp = useCallback(() => {
addMutation({
chatId,
message: "👍",
sender: userData.id,
});
setMessage("");
}, [chatId, addMutation, userData]);
const handleSend = () => {
if (message.length !== 0) {
addMutation({
chatId,
message: message.trim(),
sender: userData.id,
});
const handleSend = () => {
if (message.length !== 0) {
addMutation({
chatId,
message: message.trim(),
sender: userData.id,
});
setMessage("");
setMessage("");
if (inputRef.current) {
inputRef.current.textArea.focus();
inputRef.current.textArea.style.height = "auto";
}
}
};
const isTypingMutation = useThrottledIsTypingMutation({
chatId,
username: userData.username,
});
if (inputRef.current) {
inputRef.current.textArea.focus();
inputRef.current.textArea.style.height = "auto";
}
}
};
const isTypingMutation = useThrottledIsTypingMutation({
chatId,
username: userData.username,
});
useEffect(() => {
// update isTyping state
isTypingMutation(isFocused && message.trim().length > 0);
}, [isFocused, message, isTypingMutation]);
useEffect(() => {
// update isTyping state
isTypingMutation(isFocused && message.trim().length > 0);
}, [isFocused, message, isTypingMutation]);
useEffect(() => {
if (inputRef.current) {
inputRef.current.textArea.focus();
}
}, []);
const [openEmoji, setOpenEmoji] = useState(false);
useEffect(() => {
if (inputRef.current) {
inputRef.current.textArea.focus();
}
}, []);
const [openEmoji, setOpenEmoji] = useState(false);
const isMobile = useMediaQuery("(min-width: 426px)");
return (
<div className="flex min-h-[74px] w-full items-center justify-between gap-2 p-2">
<div className="flex">
<ChatAttachments userData={userData} chatUserData={chatUserData} />
</div>
<div className="relative w-full">
<AutosizeTextarea
autoComplete="off"
value={message}
minHeight={56}
ref={inputRef}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
setMessage((prev) => `${prev}\n`);
} else {
handleSend();
}
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
}}
name="message"
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"
/>
{isMobile && (
<div className="absolute top-0 right-2 flex h-full items-center">
<Popover open={openEmoji} onOpenChange={setOpenEmoji}>
<PopoverTrigger asChild>
<Button variant="ghost">
<SmilePlusIcon className="stroke-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-fit p-0">
<EmojiPicker
locale={locale as "it" | "en"}
className="h-84"
onEmojiSelect={({ emoji }) => {
setMessage(message + emoji);
// if (inputRef.current) {
// inputRef.current.textArea.focus();
// }
}}
>
<EmojiPickerSearch
placeholder={locale == "en" ? "Search..." : "Cerca..."}
/>
<EmojiPickerContent />
</EmojiPicker>
</PopoverContent>
</Popover>
</div>
)}
</div>
<Button
aria-label="Send Message"
variant="ghost"
className="shrink-0"
onClick={
message.length != 0 ? () => handleSend() : () => handleThumbsUp()
}
>
{message.length != 0 ? (
<SendHorizontal size={20} className="text-muted-foreground" />
) : (
<ThumbsUp size={20} className="text-muted-foreground" />
)}
</Button>
</div>
);
const isMobile = useMediaQuery("(min-width: 426px)");
return (
<div className="flex min-h-[74px] w-full items-center justify-between gap-2 p-2">
<div className="flex">
<ChatAttachments userData={userData} chatUserData={chatUserData} />
</div>
<div className="relative w-full">
<AutosizeTextarea
autoComplete="off"
value={message}
minHeight={56}
ref={inputRef}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
if (e.shiftKey) {
setMessage((prev) => `${prev}\n`);
} else {
handleSend();
}
}
}}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => {
setMessage(e.target.value);
}}
name="message"
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"
/>
{isMobile && (
<div className="absolute top-0 right-2 flex h-full items-center">
<Popover open={openEmoji} onOpenChange={setOpenEmoji}>
<PopoverTrigger asChild>
<Button variant="ghost">
<SmilePlusIcon className="stroke-muted-foreground" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-fit p-0">
<EmojiPicker
locale={locale as "it" | "en"}
className="h-84"
onEmojiSelect={({ emoji }) => {
setMessage(message + emoji);
// if (inputRef.current) {
// inputRef.current.textArea.focus();
// }
}}
>
<EmojiPickerSearch
placeholder={locale === "en" ? "Search..." : "Cerca..."}
/>
<EmojiPickerContent />
</EmojiPicker>
</PopoverContent>
</Popover>
</div>
)}
</div>
<Button
aria-label="Send Message"
variant="ghost"
className="shrink-0"
onClick={
message.length !== 0 ? () => handleSend() : () => handleThumbsUp()
}
>
{message.length !== 0 ? (
<SendHorizontal size={20} className="text-muted-foreground" />
) : (
<ThumbsUp size={20} className="text-muted-foreground" />
)}
</Button>
</div>
);
}

View file

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

View file

@ -1,439 +1,440 @@
"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 { Pencil, Trash2 } from "lucide-react";
import {
ChatBubble,
ChatBubbleAction,
ChatBubbleActionWrapper,
ChatBubbleMessage,
ChatBubbleReadStatus,
ChatBubbleTimestamp,
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import toast from "react-hot-toast";
import {
ChatBubble,
ChatBubbleAction,
ChatBubbleActionWrapper,
ChatBubbleMessage,
ChatBubbleReadStatus,
ChatBubbleTimestamp,
} from "~/components/chat/chat-bubble";
import { ChatMessageList } from "~/components/chat/chat-message-list";
import {
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 Input from "~/components/custom_ui/input";
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 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 = {
chatId: ChatsChatid;
messages: ChatMessage[];
userData: Session;
query: LiveChat["query"];
typingStatus: WhoIsTyping[];
chatId: ChatsChatid;
messages: ChatMessage[];
userData: Session;
query: LiveChat["query"];
typingStatus: WhoIsTyping[];
};
export const ChatList = ({
chatId,
messages,
userData,
query,
typingStatus,
chatId,
messages,
userData,
query,
typingStatus,
}: ChatListProps) => {
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [msgSelected, setMsgSelected] = useState<ChatMessage | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const messagesContainerRef = useRef<HTMLDivElement>(null);
const [editModalOpen, setEditModalOpen] = useState(false);
const [msgSelected, setMsgSelected] = useState<ChatMessage | null>(null);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const handleEditOpen = (status: boolean) => {
setEditModalOpen(status);
};
const handleDeleteOpen = (status: boolean) => {
setDeleteModalOpen(status);
};
const handleEditOpen = (status: boolean) => {
setEditModalOpen(status);
};
const handleDeleteOpen = (status: boolean) => {
setDeleteModalOpen(status);
};
const { mutate: deleteMutation } = api.messagesSSE.delete.useMutation({
onMutate: () => {
toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" });
},
onSuccess: () => {
toast("Message deleted", { icon: "🗑️", id: "deleteMessage" });
},
const { mutate: deleteMutation } = api.messagesSSE.delete.useMutation({
onMutate: () => {
toast("Deleting message...", { icon: "🗑️", id: "deleteMessage" });
},
onSuccess: () => {
toast("Message deleted", { icon: "🗑️", id: "deleteMessage" });
},
onError: () => {
toast.error("Error deleting message", { id: "deleteMessage" });
},
});
const { mutate: editMutation } = api.messagesSSE.edit.useMutation({
onMutate: () => {
toast("Editing message...", { icon: "📝", id: "editMessage" });
},
onSuccess: () => {
toast("Message edited", { icon: "📝", id: "editMessage" });
},
onError: () => {
toast.error("Error editing message", { id: "editMessage" });
},
});
onError: () => {
toast.error("Error deleting message", { id: "deleteMessage" });
},
});
const { mutate: editMutation } = api.messagesSSE.edit.useMutation({
onMutate: () => {
toast("Editing message...", { icon: "📝", id: "editMessage" });
},
onSuccess: () => {
toast("Message edited", { icon: "📝", id: "editMessage" });
},
onError: () => {
toast.error("Error editing message", { id: "editMessage" });
},
});
const handleEditSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
if (!formData.get("message-edit") || !formData.get("message-id")) {
return;
}
editMutation({
chatId,
messageId: formData.get("message-id") as MessagesMessageid,
message: formData.get("message-edit") as string,
});
setEditModalOpen(false);
},
[editMutation],
);
const [locked, setLocked] = useState(false);
const handleEditSubmit = useCallback(
(e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
const formData = new FormData(form);
if (!formData.get("message-edit") || !formData.get("message-id")) {
return;
}
editMutation({
chatId,
messageId: formData.get("message-id") as MessagesMessageid,
message: formData.get("message-edit") as string,
});
setEditModalOpen(false);
},
[editMutation],
);
const [locked, setLocked] = useState(false);
useEffect(() => {
if (
messagesContainerRef.current &&
!locked &&
!query.isLoading &&
!query.isFetching
) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
setLocked(false);
}, [messages, typingStatus]);
useEffect(() => {
if (
messagesContainerRef.current &&
!locked &&
!query.isLoading &&
!query.isFetching
) {
messagesContainerRef.current.scrollTop =
messagesContainerRef.current.scrollHeight;
}
setLocked(false);
}, [messages, typingStatus]);
return (
<div className="flex h-full w-full flex-col overflow-y-auto">
<ChatMessageList ref={messagesContainerRef}>
<AnimatePresence key="chat-messages">
{query.hasNextPage && (
<LoadingButton
aria-label="Load More Messages"
loading={query.isFetchingNextPage}
variant="secondary"
key="load-more"
disabled={!query.hasNextPage || query.isFetchingNextPage}
onClick={async () => {
setLocked(true);
await query.fetchNextPage();
}}
className="mb-3"
>
{query.isFetchingNextPage
? "Caricamento..."
: "Carica messaggi precedenti"}
</LoadingButton>
)}
return (
<div className="flex h-full w-full flex-col overflow-y-auto">
<ChatMessageList ref={messagesContainerRef}>
<AnimatePresence key="chat-messages">
{query.hasNextPage && (
<LoadingButton
aria-label="Load More Messages"
loading={query.isFetchingNextPage}
variant="secondary"
key="load-more"
disabled={!query.hasNextPage || query.isFetchingNextPage}
onClick={async () => {
setLocked(true);
await query.fetchNextPage();
}}
className="mb-3"
>
{query.isFetchingNextPage
? "Caricamento..."
: "Carica messaggi precedenti"}
</LoadingButton>
)}
{messages.map((message, index) => {
const variant = getMessageVariant(message.sender, userData.id);
{messages.map((message, index) => {
const variant = getMessageVariant(message.sender, userData.id);
const isNextSameSender =
index + 1 < messages.length &&
messages[index + 1]?.sender == message.sender;
const isNextSameSender =
index + 1 < messages.length &&
messages[index + 1]?.sender === message.sender;
const isPreviousSameSender =
index - 1 >= 0 && messages[index - 1]?.sender == message.sender;
const isPreviousSameSender =
index - 1 >= 0 && messages[index - 1]?.sender === message.sender;
const isPrevSameDay =
index - 1 >= 0 &&
new Date(messages[index - 1]!.time).toDateString() ===
new Date(message.time).toDateString();
const isPrevSameDay =
index - 1 >= 0 &&
// biome-ignore lint/style/noNonNullAssertion: <non lo so>
new Date(messages[index - 1]!.time).toDateString() ===
new Date(message.time).toDateString();
return (
<motion.div
key={message.messageid}
layout
initial={{ opacity: 0, scale: 1, y: 50, x: 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 1, y: 1, x: 0 }}
transition={{
opacity: { duration: 0.1 },
layout: {
type: "spring",
bounce: 0.3,
duration: 0.3,
},
}}
style={{ originX: 0.5, originY: 0.5 }}
className={cn(
"flex flex-col gap-2 pb-4",
isNextSameSender && "pb-0.5",
)}
>
{!isPrevSameDay && (
<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", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})}
</span>
)}
<ChatBubble variant={variant}>
{isNextSameSender ? (
<div className="size-6"></div>
) : (
<UserAvatar userId={message.sender} className="size-6" />
)}
return (
<motion.div
key={message.messageid}
layout
initial={{ opacity: 0, scale: 1, y: 50, x: 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 1, y: 1, x: 0 }}
transition={{
opacity: { duration: 0.1 },
layout: {
type: "spring",
bounce: 0.3,
duration: 0.3,
},
}}
style={{ originX: 0.5, originY: 0.5 }}
className={cn(
"flex flex-col gap-2 pb-4",
isNextSameSender && "pb-0.5",
)}
>
{!isPrevSameDay && (
<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", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
})}
</span>
)}
<ChatBubble variant={variant}>
{isNextSameSender ? (
<div className="size-6"></div>
) : (
<UserAvatar userId={message.sender} className="size-6" />
)}
<div className="flex flex-col gap-1">
{!isPreviousSameSender && (
<p
className={cn(
"text-sm font-semibold",
variant === "sent" && "text-right",
)}
style={{
color: getUserColor(message.sender),
}}
>
{message.sender_nome}
</p>
)}
<ChatBubbleMessage
isLoading={false}
className={cn(isNextSameSender && "rounded-lg")}
>
{message.message}
<div className="flex items-center gap-2">
{userData.id == message.sender && (
<ChatBubbleReadStatus isread={message.isread} />
)}
{message.time && (
<ChatBubbleTimestamp
timestamp={new Date(message.time).toLocaleString(
"it",
{
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
},
)}
/>
)}
</div>
</ChatBubbleMessage>
</div>
<ChatBubbleActionWrapper show={userData.isAdmin}>
<ChatBubbleAction
className="size-7 hover:bg-red-50 hover:text-red-500"
key={"delete"}
icon={<Trash2 className="size-4" />}
onClick={() => {
setMsgSelected(message);
handleDeleteOpen(true);
}}
/>
<ChatBubbleAction
className="size-7 hover:bg-blue-50 hover:text-blue-500"
key={"edit"}
icon={<Pencil className="size-4" />}
onClick={() => {
setMsgSelected(message);
handleEditOpen(true);
}}
/>
</ChatBubbleActionWrapper>
</ChatBubble>
</motion.div>
);
})}
<div className="flex flex-col gap-1">
{!isPreviousSameSender && (
<p
className={cn(
"text-sm font-semibold",
variant === "sent" && "text-right",
)}
style={{
color: getUserColor(message.sender),
}}
>
{message.sender_nome}
</p>
)}
<ChatBubbleMessage
isLoading={false}
className={cn(isNextSameSender && "rounded-lg")}
>
{message.message}
<div className="flex items-center gap-2">
{userData.id === message.sender && (
<ChatBubbleReadStatus isread={message.isread} />
)}
{message.time && (
<ChatBubbleTimestamp
timestamp={new Date(message.time).toLocaleString(
"it",
{
day: "2-digit",
month: "short",
hour: "2-digit",
minute: "2-digit",
},
)}
/>
)}
</div>
</ChatBubbleMessage>
</div>
<ChatBubbleActionWrapper show={userData.isAdmin}>
<ChatBubbleAction
className="size-7 hover:bg-red-50 hover:text-red-500"
key={"delete"}
icon={<Trash2 className="size-4" />}
onClick={() => {
setMsgSelected(message);
handleDeleteOpen(true);
}}
/>
<ChatBubbleAction
className="size-7 hover:bg-blue-50 hover:text-blue-500"
key={"edit"}
icon={<Pencil className="size-4" />}
onClick={() => {
setMsgSelected(message);
handleEditOpen(true);
}}
/>
</ChatBubbleActionWrapper>
</ChatBubble>
</motion.div>
);
})}
<Typers
currentTypers={typingStatus}
messageLenght={messages.length}
userId={userData.id}
/>
</AnimatePresence>
</ChatMessageList>
<EditMessageDialog
open={editModalOpen}
onOpenChange={handleEditOpen}
msgEdit={msgSelected}
handleEditSubmit={handleEditSubmit}
/>
<DeleteMessageDialog
open={deleteModalOpen}
onOpenChange={handleDeleteOpen}
msgDelete={msgSelected}
handleDeleteSubmit={(messageId) => {
deleteMutation({ chatId, messageId });
}}
/>
</div>
);
<Typers
currentTypers={typingStatus}
messageLenght={messages.length}
userId={userData.id}
/>
</AnimatePresence>
</ChatMessageList>
<EditMessageDialog
open={editModalOpen}
onOpenChange={handleEditOpen}
msgEdit={msgSelected}
handleEditSubmit={handleEditSubmit}
/>
<DeleteMessageDialog
open={deleteModalOpen}
onOpenChange={handleDeleteOpen}
msgDelete={msgSelected}
handleDeleteSubmit={(messageId) => {
deleteMutation({ chatId, messageId });
}}
/>
</div>
);
};
const Typers = ({
currentTypers,
messageLenght,
userId,
currentTypers,
messageLenght,
userId,
}: {
currentTypers: WhoIsTyping[];
messageLenght: number;
userId: UsersId;
currentTypers: WhoIsTyping[];
messageLenght: number;
userId: UsersId;
}) => {
return (
<>
{currentTypers.map((typer, index) => {
if (typer.userId === userId) return null;
const variant = "received";
return (
<>
{currentTypers.map((typer, index) => {
if (typer.userId === userId) return null;
const variant = "received";
return (
<motion.div
key={"typers" + messageLenght + index}
layout
initial={{ opacity: 0, scale: 1, y: 50, x: 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 1, y: 1, x: 0 }}
transition={{
opacity: { duration: 0.1 },
layout: {
type: "spring",
bounce: 0.3,
duration: 0.3,
},
}}
style={{ originX: 0.5, originY: 0.5 }}
className="flex flex-col gap-2 py-2"
>
<ChatBubble variant={variant}>
<UserAvatar userId={typer.userId} className="size-6" />
return (
<motion.div
key={`typers${messageLenght}${index}`}
layout
initial={{ opacity: 0, scale: 1, y: 50, x: 0 }}
animate={{ opacity: 1, scale: 1, y: 0, x: 0 }}
exit={{ opacity: 0, scale: 1, y: 1, x: 0 }}
transition={{
opacity: { duration: 0.1 },
layout: {
type: "spring",
bounce: 0.3,
duration: 0.3,
},
}}
style={{ originX: 0.5, originY: 0.5 }}
className="flex flex-col gap-2 py-2"
>
<ChatBubble variant={variant}>
<UserAvatar userId={typer.userId} className="size-6" />
<ChatBubbleMessage isLoading={true}></ChatBubbleMessage>
</ChatBubble>
</motion.div>
);
})}
</>
);
<ChatBubbleMessage isLoading={true}></ChatBubbleMessage>
</ChatBubble>
</motion.div>
);
})}
</>
);
};
const EditMessageDialog = ({
open,
onOpenChange,
msgEdit,
handleEditSubmit,
open,
onOpenChange,
msgEdit,
handleEditSubmit,
}: {
open: boolean;
onOpenChange: (status: boolean) => void;
msgEdit: ChatMessage | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
open: boolean;
onOpenChange: (status: boolean) => void;
msgEdit: ChatMessage | null;
handleEditSubmit: (e: FormEvent<HTMLFormElement>) => void;
}) => {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader>
{msgEdit && (
<form onSubmit={handleEditSubmit} method="post">
<div className="flex items-center gap-3">
<label htmlFor="id" className="sr-only">
ID
</label>
<Input
id="id"
type="text"
className="hidden"
name="message-id"
value={msgEdit.messageid}
readOnly
/>
<label htmlFor="message-edit" className="sr-only">
Modifica messaggio
</label>
<Input
id="message-edit"
type="text"
placeholder="Modifica messaggio"
className="mt-0"
name="message-edit"
defaultValue={msgEdit.message || undefined}
/>
<Button type="submit">{t.salva}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t.modifica}</DialogTitle>
<DialogDescription className="sr-only">Edit</DialogDescription>
</DialogHeader>
{msgEdit && (
<form onSubmit={handleEditSubmit} method="post">
<div className="flex items-center gap-3">
<label htmlFor="id" className="sr-only">
ID
</label>
<Input
id="id"
type="text"
className="hidden"
name="message-id"
value={msgEdit.messageid}
readOnly
/>
<label htmlFor="message-edit" className="sr-only">
Modifica messaggio
</label>
<Input
id="message-edit"
type="text"
placeholder="Modifica messaggio"
className="mt-0"
name="message-edit"
defaultValue={msgEdit.message || undefined}
/>
<Button type="submit">{t.salva}</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
);
};
const DeleteMessageDialog = ({
open,
onOpenChange,
msgDelete,
handleDeleteSubmit,
open,
onOpenChange,
msgDelete,
handleDeleteSubmit,
}: {
open: boolean;
onOpenChange: (status: boolean) => void;
msgDelete: ChatMessage | null;
handleDeleteSubmit: (msgId: MessagesMessageid) => void;
open: boolean;
onOpenChange: (status: boolean) => void;
msgDelete: ChatMessage | null;
handleDeleteSubmit: (msgId: MessagesMessageid) => void;
}) => {
const { t } = useTranslation();
return (
<>
{msgDelete && (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Vuoi eliminare il messaggio?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
aria-label="Delete Message"
onClick={() => handleDeleteSubmit(msgDelete.messageid)}
>
{t.procedi}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
const { t } = useTranslation();
return (
<>
{msgDelete && (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Vuoi eliminare il messaggio?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t.annulla}</AlertDialogCancel>
<AlertDialogAction
aria-label="Delete Message"
onClick={() => handleDeleteSubmit(msgDelete.messageid)}
>
{t.procedi}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</>
);
};
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 { cn } from "~/lib/utils";
import { Button, buttonVariants } from "~/components/ui/button";
import { useMediaQuery } from "~/hooks/use-media-query";
import Link from "next/link";
import { useRouter } from "next/router";
import { useCallback, useState } from "react";
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 type { NonNullableUser } from "~/server/controllers/user.controller";
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 { 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 { NonNullableUser } from "~/server/controllers/user.controller";
import { api } from "~/utils/api";
type LastMessage = {
message: string | null;
time: Date;
isLastSender: boolean;
message: string | null;
time: Date;
isLastSender: boolean;
};
export const ChatSidebar = ({ chatId }: { chatId: ChatsChatid | null }) => {
const { data: activeChats, isLoading: loadingActiveChats } =
api.chat.getActiveChats.useQuery(undefined, {
refetchInterval: 5000, // Refetch every 5 seconds, Good enough for a chat sidebar
refetchOnWindowFocus: true,
});
const { data: inactiveUsers, isLoading: loadingInactiveUsers } =
api.users.getActiveUsersWithoutChat.useQuery();
const { data: activeChats, isLoading: loadingActiveChats } =
api.chat.getActiveChats.useQuery(undefined, {
refetchInterval: 5000, // Refetch every 5 seconds, Good enough for a chat sidebar
refetchOnWindowFocus: true,
});
const { data: inactiveUsers, isLoading: loadingInactiveUsers } =
api.users.getActiveUsersWithoutChat.useQuery();
const router = useRouter();
const isDesktop = useMediaQuery("(min-width: 768px)");
const [selectedChat, setSelectedChat] = useState<ChatsChatid | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [searchDialogOpen, setSearchDialogOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [editEtichettaModalOpen, setEditEtichettaModalOpen] = useState(false);
const router = useRouter();
const isDesktop = useMediaQuery("(min-width: 768px)");
const [selectedChat, setSelectedChat] = useState<ChatsChatid | null>(null);
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [searchDialogOpen, setSearchDialogOpen] = useState(false);
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const [editEtichettaModalOpen, setEditEtichettaModalOpen] = useState(false);
const utils = api.useUtils();
const { mutate: deletechat } = api.chat.deleteChat.useMutation({
onSuccess: async () => {
await utils.chat.getActiveChats.invalidate();
await utils.users.getActiveUsersWithoutChat.invalidate();
},
});
const utils = api.useUtils();
const { mutate: deletechat } = api.chat.deleteChat.useMutation({
onSuccess: async () => {
await utils.chat.getActiveChats.invalidate();
await utils.users.getActiveUsersWithoutChat.invalidate();
},
});
const getVariant = (lastmessage: LastMessage | null) => {
if (lastmessage) {
return lastmessage.isLastSender ? "secondary" : "grey";
}
return "grey";
};
const getLastMessage = (lastmessage: LastMessage | null) => {
if (lastmessage) {
return `${lastmessage.isLastSender ? "Tu: " : ""}${lastmessage.message}`;
}
return "";
};
const getVariant = (lastmessage: LastMessage | null) => {
if (lastmessage) {
return lastmessage.isLastSender ? "secondary" : "grey";
}
return "grey";
};
const getLastMessage = (lastmessage: LastMessage | null) => {
if (lastmessage) {
return `${lastmessage.isLastSender ? "Tu: " : ""}${lastmessage.message}`;
}
return "";
};
const handleChatSelection = useCallback(
async (new_chatid: ChatsChatid) => {
if (chatId === new_chatid) return;
await router.push(`/area-riservata/admin/chats/${new_chatid}`);
setSearchDialogOpen(false);
},
[chatId, router],
);
const handleChatSelection = useCallback(
async (new_chatid: ChatsChatid) => {
if (chatId === new_chatid) return;
await router.push(`/area-riservata/admin/chats/${new_chatid}`);
setSearchDialogOpen(false);
},
[chatId, router],
);
if (loadingActiveChats || loadingInactiveUsers) return <LoadingPage />;
if (!activeChats || !inactiveUsers) return <div>Errore caricamento</div>;
if (loadingActiveChats || loadingInactiveUsers) return <LoadingPage />;
if (!activeChats || !inactiveUsers) return <div>Errore caricamento</div>;
return (
<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">
{isDesktop ? (
<div className="relative">
<Search className="text-muted-foreground absolute top-[0.8rem] left-2 size-4" />
<label htmlFor="search" className="sr-only">
Search Chats
</label>
<Input placeholder="Search" className="m-0 pl-8" id="search" />
</div>
) : (
<Button
aria-label="Open Search Dialog"
onClick={() => setSearchDialogOpen(true)}
variant="ghost"
size="icon"
className="size-8"
>
<Search size={20} />
</Button>
)}
return (
<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">
{isDesktop ? (
<div className="relative">
<Search className="text-muted-foreground absolute top-[0.8rem] left-2 size-4" />
<label htmlFor="search" className="sr-only">
Search Chats
</label>
<Input placeholder="Search" className="m-0 pl-8" id="search" />
</div>
) : (
<Button
aria-label="Open Search Dialog"
onClick={() => setSearchDialogOpen(true)}
variant="ghost"
size="icon"
className="size-8"
>
<Search size={20} />
</Button>
)}
<SearchUserComponent
activeChats={activeChats}
isOpen={searchDialogOpen}
OpenChange={setSearchDialogOpen}
handleChatSelection={handleChatSelection}
/>
<Button
aria-label="Open Add User Dialog"
onClick={() => setAddDialogOpen(true)}
variant="ghost"
size="icon"
className="size-8"
>
<SquarePen size={20} />
</Button>
<SearchUserComponent
activeChats={activeChats}
isOpen={searchDialogOpen}
OpenChange={setSearchDialogOpen}
handleChatSelection={handleChatSelection}
/>
<Button
aria-label="Open Add User Dialog"
onClick={() => setAddDialogOpen(true)}
variant="ghost"
size="icon"
className="size-8"
>
<SquarePen size={20} />
</Button>
<NewUserComponent
inactiveUsers={inactiveUsers}
isOpen={addDialogOpen}
OpenChange={setAddDialogOpen}
/>
</div>
<NewUserComponent
inactiveUsers={inactiveUsers}
isOpen={addDialogOpen}
OpenChange={setAddDialogOpen}
/>
</div>
<nav className="grid gap-1 rounded-lg group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{activeChats.map((chat) => (
<ContextMenu key={chat.userinfo.username}>
<ContextMenuTrigger asChild>
<Button
aria-label="Select Chat"
onClick={() => handleChatSelection(chat.id)}
className={cn(
buttonVariants({
variant: getVariant(chat.lastmessage),
size: !isDesktop ? "icon" : "xl",
}),
<nav className="grid gap-1 rounded-lg group-[[data-collapsed=true]]:justify-center group-[[data-collapsed=true]]:px-2">
{activeChats.map((chat) => (
<ContextMenu key={chat.userinfo.username}>
<ContextMenuTrigger asChild>
<Button
aria-label="Select Chat"
onClick={() => handleChatSelection(chat.id)}
className={cn(
buttonVariants({
variant: getVariant(chat.lastmessage),
size: !isDesktop ? "icon" : "xl",
}),
!isDesktop ? "p-1" : "justify-start gap-4",
"h-fit w-full",
)}
>
<UserAvatar
username={chat.userinfo.username}
userId={chat.userinfo.id}
className="size-10"
/>
!isDesktop ? "p-1" : "justify-start gap-4",
"h-fit w-full",
)}
>
<UserAvatar
username={chat.userinfo.username}
userId={chat.userinfo.id}
className="size-10"
/>
{isDesktop && (
<div className="flex h-full max-w-28 flex-col text-left align-top">
<span>{chat.userinfo.username}</span>
<div className="flex flex-wrap gap-2">
{chat.etichette.map((etichetta) => (
<Etichetta
key={etichetta.id_etichetta}
title={etichetta.title}
color_hex={etichetta.color_hex}
className="text-xs"
/>
))}
</div>
{isDesktop && (
<div className="flex h-full max-w-28 flex-col text-left align-top">
<span>{chat.userinfo.username}</span>
<div className="flex flex-wrap gap-2">
{chat.etichette.map((etichetta) => (
<Etichetta
key={etichetta.id_etichetta}
title={etichetta.title}
color_hex={etichetta.color_hex}
className="text-xs"
/>
))}
</div>
<span className="text-muted-foreground truncate text-xs">
{getLastMessage(chat.lastmessage)}
</span>
<span className="text-muted-foreground truncate text-xs">
{chat.lastmessage &&
new Date(chat.lastmessage.time).toLocaleString("it", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
</Button>
</ContextMenuTrigger>
<ChatSidebarContextMenu
chatid={chat.id}
setSelectedChat={setSelectedChat}
setDeleteModalOpen={setDeleteModalOpen}
setEditEtichettaModalOpen={setEditEtichettaModalOpen}
/>
</ContextMenu>
))}
{selectedChat && (
<>
<AlertDialog
open={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata. Questo eliminerà
definitivamente la chat e rimuoverà i dati dai nostri
server.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogAction
aria-label="Delete Chat"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => {
if (selectedChat) {
deletechat({
chatId: selectedChat,
});
}
}}
>
Continua
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<EtichetteModal
open={editEtichettaModalOpen}
onOpenChange={setEditEtichettaModalOpen}
chatId={selectedChat}
/>
</>
)}
</nav>
</div>
);
<span className="text-muted-foreground truncate text-xs">
{getLastMessage(chat.lastmessage)}
</span>
<span className="text-muted-foreground truncate text-xs">
{chat.lastmessage &&
new Date(chat.lastmessage.time).toLocaleString("it", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
)}
</Button>
</ContextMenuTrigger>
<ChatSidebarContextMenu
chatid={chat.id}
setSelectedChat={setSelectedChat}
setDeleteModalOpen={setDeleteModalOpen}
setEditEtichettaModalOpen={setEditEtichettaModalOpen}
/>
</ContextMenu>
))}
{selectedChat && (
<>
<AlertDialog
open={deleteModalOpen}
onOpenChange={setDeleteModalOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Sei assolutamente sicuro?</AlertDialogTitle>
<AlertDialogDescription>
Questa azione non può essere annullata. Questo eliminerà
definitivamente la chat e rimuoverà i dati dai nostri
server.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Annulla</AlertDialogCancel>
<AlertDialogAction
aria-label="Delete Chat"
className={cn(buttonVariants({ variant: "destructive" }))}
onClick={() => {
if (selectedChat) {
deletechat({
chatId: selectedChat,
});
}
}}
>
Continua
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<EtichetteModal
open={editEtichettaModalOpen}
onOpenChange={setEditEtichettaModalOpen}
chatId={selectedChat}
/>
</>
)}
</nav>
</div>
);
};
const SearchUserComponent = ({
isOpen,
OpenChange,
activeChats,
handleChatSelection,
isOpen,
OpenChange,
activeChats,
handleChatSelection,
}: {
isOpen: boolean;
OpenChange: (status: boolean) => void;
activeChats: ActiveChatsType[];
handleChatSelection: (chatId: ChatsChatid) => void;
isOpen: boolean;
OpenChange: (status: boolean) => void;
activeChats: ActiveChatsType[];
handleChatSelection: (chatId: ChatsChatid) => void;
}) => {
return (
<CommandDialog open={isOpen} onOpenChange={OpenChange}>
<CommandInput
placeholder="Digita il nome utente..."
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup
className="pb-2"
heading={activeChats.length > 0 ? "Suggerimenti" : undefined}
>
{activeChats?.map((chat) => (
<CommandItem
className="rounded-md"
key={chat.id}
onSelect={() => {
handleChatSelection(chat.id);
}}
>
<span className="flex items-center gap-2">
<UserAvatar
username={chat.userinfo.username}
userId={chat.userinfo.id}
className="size-8"
/>
return (
<CommandDialog open={isOpen} onOpenChange={OpenChange}>
<CommandInput
placeholder="Digita il nome utente..."
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup
className="pb-2"
heading={activeChats.length > 0 ? "Suggerimenti" : undefined}
>
{activeChats?.map((chat) => (
<CommandItem
className="rounded-md"
key={chat.id}
onSelect={() => {
handleChatSelection(chat.id);
}}
>
<span className="flex items-center gap-2">
<UserAvatar
username={chat.userinfo.username}
userId={chat.userinfo.id}
className="size-8"
/>
<span>
<span className="block font-medium">
{chat.userinfo.username}
</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
<span>
<span className="block font-medium">
{chat.userinfo.username}
</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};
const NewUserComponent = ({
isOpen,
OpenChange,
inactiveUsers,
isOpen,
OpenChange,
inactiveUsers,
}: {
isOpen: boolean;
OpenChange: (status: boolean) => void;
inactiveUsers: NonNullableUser[];
isOpen: boolean;
OpenChange: (status: boolean) => void;
inactiveUsers: NonNullableUser[];
}) => {
const utils = api.useUtils();
const { mutate: createNewChat } = api.chatSSE.create.useMutation({
onSuccess: async () => {
await utils.chat.getActiveChats.invalidate();
OpenChange(false);
},
});
return (
<CommandDialog open={isOpen} onOpenChange={OpenChange}>
<CommandInput
placeholder="Digita il nome utente..."
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup
className="pb-2"
heading={inactiveUsers.length > 0 ? "Suggerimenti" : undefined}
>
{inactiveUsers?.map((user) => (
<CommandItem
className="rounded-md"
key={user.id}
onSelect={() => {
createNewChat({
userId: user.id,
});
}}
>
<span className="flex items-center gap-2">
<UserAvatar
username={user.username}
userId={user.id}
className="size-8"
/>
const utils = api.useUtils();
const { mutate: createNewChat } = api.chatSSE.create.useMutation({
onSuccess: async () => {
await utils.chat.getActiveChats.invalidate();
OpenChange(false);
},
});
return (
<CommandDialog open={isOpen} onOpenChange={OpenChange}>
<CommandInput
placeholder="Digita il nome utente..."
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/>
<CommandList>
<CommandEmpty>Nessun risultato</CommandEmpty>
<CommandGroup
className="pb-2"
heading={inactiveUsers.length > 0 ? "Suggerimenti" : undefined}
>
{inactiveUsers?.map((user) => (
<CommandItem
className="rounded-md"
key={user.id}
onSelect={() => {
createNewChat({
userId: user.id,
});
}}
>
<span className="flex items-center gap-2">
<UserAvatar
username={user.username}
userId={user.id}
className="size-8"
/>
<span>
<span className="block font-medium">{user.username}</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
<span>
<span className="block font-medium">{user.username}</span>
</span>
</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</CommandDialog>
);
};
const ChatSidebarContextMenu = ({
chatid,
setSelectedChat,
setDeleteModalOpen,
setEditEtichettaModalOpen,
chatid,
setSelectedChat,
setDeleteModalOpen,
setEditEtichettaModalOpen,
}: {
chatid: ChatsChatid;
setSelectedChat: (chat: ChatsChatid | null) => void;
setDeleteModalOpen: (status: boolean) => void;
setEditEtichettaModalOpen: (status: boolean) => void;
chatid: ChatsChatid;
setSelectedChat: (chat: ChatsChatid | null) => void;
setDeleteModalOpen: (status: boolean) => void;
setEditEtichettaModalOpen: (status: boolean) => void;
}) => {
const { data: chatData } = api.chat.getChatInfobyId.useQuery({
chatId: chatid,
});
if (!chatData) return null;
const { data: chatData } = api.chat.getChatInfobyId.useQuery({
chatId: chatid,
});
if (!chatData) return null;
return (
<>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Etichette</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem asChild>
<button
aria-label="Edit Chat Labels"
onClick={() => {
setSelectedChat(chatid);
setEditEtichettaModalOpen(true);
}}
className="w-full cursor-pointer"
>
Modifica
</button>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem>
<Link
aria-label="View User Profile"
target="_blank"
href={`/area-riservata/admin/user-view/edit-user/${chatData.user_ref}`}
>
Profilo
</Link>
</ContextMenuItem>
return (
<>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Etichette</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem asChild>
<button
type="button"
aria-label="Edit Chat Labels"
onClick={() => {
setSelectedChat(chatid);
setEditEtichettaModalOpen(true);
}}
className="w-full cursor-pointer"
>
Modifica
</button>
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<ContextMenuSeparator />
<ContextMenuItem>
<Link
aria-label="View User Profile"
target="_blank"
href={`/area-riservata/admin/user-view/edit-user/${chatData.user_ref}`}
>
Profilo
</Link>
</ContextMenuItem>
<ContextMenuItem>
<Link
aria-label="View User Orders"
target="_blank"
href={`/area-riservata/admin/user-view/ordini/${chatData.user_ref}`}
>
Ordini
</Link>
</ContextMenuItem>
<ContextMenuItem>
<Link
aria-label="View User Attachments"
target="_blank"
href={`/area-riservata/admin/user-view/allegati/${chatData.user_ref}`}
>
Allegati
</Link>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Altro</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem
aria-label="Edit Chat"
onClick={() => {
setSelectedChat(chatid);
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"
>
Elimina Chat
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</>
);
<ContextMenuItem>
<Link
aria-label="View User Orders"
target="_blank"
href={`/area-riservata/admin/user-view/ordini/${chatData.user_ref}`}
>
Ordini
</Link>
</ContextMenuItem>
<ContextMenuItem>
<Link
aria-label="View User Attachments"
target="_blank"
href={`/area-riservata/admin/user-view/allegati/${chatData.user_ref}`}
>
Allegati
</Link>
</ContextMenuItem>
<ContextMenuSub>
<ContextMenuSubTrigger>Altro</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<ContextMenuItem
aria-label="Edit Chat"
onClick={() => {
setSelectedChat(chatid);
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"
>
Elimina Chat
</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</>
);
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,55 +1,56 @@
"use client";
import { useEffect } from "react";
import {
type MotionValue,
motion,
useSpring,
useTransform,
type MotionValue,
motion,
useSpring,
useTransform,
} from "framer-motion";
import { useEffect } from "react";
//DOC: https://www.cult-ui.com/docs/components/animated-number
interface AnimatedNumberProps {
value: number;
mass?: number;
stiffness?: number;
damping?: number;
precision?: number;
format?: (value: number) => string;
onAnimationStart?: () => void;
onAnimationComplete?: () => void;
value: number;
mass?: number;
stiffness?: number;
damping?: number;
precision?: number;
format?: (value: number) => string;
onAnimationStart?: () => void;
onAnimationComplete?: () => void;
}
export function AnimatedNumber({
value,
mass = 0.8,
stiffness = 75,
damping = 15,
precision = 0,
format,
onAnimationStart,
onAnimationComplete,
value,
mass = 0.8,
stiffness = 75,
damping = 15,
precision = 0,
format,
onAnimationStart,
onAnimationComplete,
}: AnimatedNumberProps) {
const spring = useSpring(value, {
mass,
stiffness,
damping,
});
const spring = useSpring(value, {
mass,
stiffness,
damping,
});
const display: MotionValue<string> = useTransform(spring, (current) =>
format
? format(parseFloat(current.toFixed(precision)))
: parseFloat(current.toFixed(precision)).toLocaleString(),
);
const display: MotionValue<string> = useTransform(spring, (current) =>
format
? format(parseFloat(current.toFixed(precision)))
: parseFloat(current.toFixed(precision)).toLocaleString(),
);
useEffect(() => {
spring.set(value);
if (onAnimationStart) onAnimationStart();
const unsubscribe = spring.on("change", () => {
if (spring.get() === value && onAnimationComplete) onAnimationComplete();
});
return () => unsubscribe();
}, [spring, value, onAnimationStart, onAnimationComplete]);
useEffect(() => {
spring.set(value);
if (onAnimationStart) onAnimationStart();
const unsubscribe = spring.on("change", () => {
if (spring.get() === value && onAnimationComplete) onAnimationComplete();
});
return () => unsubscribe();
}, [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 {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
type Ref,
type TextareaHTMLAttributes,
forwardRef,
type Ref,
type TextareaHTMLAttributes,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
import { cn } from "~/lib/utils";
interface UseAutosizeTextAreaProps {
textAreaRef: HTMLTextAreaElement | null;
minHeight?: number;
maxHeight?: number;
triggerAutoSize: string;
textAreaRef: HTMLTextAreaElement | null;
minHeight?: number;
maxHeight?: number;
triggerAutoSize: string;
}
const useAutosizeTextArea = ({
textAreaRef,
triggerAutoSize,
maxHeight = Number.MAX_SAFE_INTEGER,
minHeight = 0,
textAreaRef,
triggerAutoSize,
maxHeight = Number.MAX_SAFE_INTEGER,
minHeight = 0,
}: UseAutosizeTextAreaProps) => {
const [init, setInit] = useState(true);
useEffect(() => {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
const offsetBorder = 2;
if (textAreaRef) {
if (init) {
textAreaRef.style.minHeight = `${minHeight + offsetBorder}px`;
if (maxHeight > minHeight) {
textAreaRef.style.maxHeight = `${maxHeight}px`;
}
setInit(false);
}
textAreaRef.style.height = `${minHeight + offsetBorder}px`;
const scrollHeight = textAreaRef.scrollHeight;
// 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.
if (scrollHeight > maxHeight) {
textAreaRef.style.height = `${maxHeight}px`;
} else {
textAreaRef.style.height = `${scrollHeight + offsetBorder}px`;
}
}
}, [textAreaRef, triggerAutoSize]);
const [init, setInit] = useState(true);
useEffect(() => {
// We need to reset the height momentarily to get the correct scrollHeight for the textarea
const offsetBorder = 2;
if (textAreaRef) {
if (init) {
textAreaRef.style.minHeight = `${minHeight + offsetBorder}px`;
if (maxHeight > minHeight) {
textAreaRef.style.maxHeight = `${maxHeight}px`;
}
setInit(false);
}
textAreaRef.style.height = `${minHeight + offsetBorder}px`;
const scrollHeight = textAreaRef.scrollHeight;
// 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.
if (scrollHeight > maxHeight) {
textAreaRef.style.height = `${maxHeight}px`;
} else {
textAreaRef.style.height = `${scrollHeight + offsetBorder}px`;
}
}
}, [textAreaRef, triggerAutoSize]);
};
export type AutosizeTextAreaRef = {
textArea: HTMLTextAreaElement;
maxHeight: number;
minHeight: number;
textArea: HTMLTextAreaElement;
maxHeight: number;
minHeight: number;
};
type AutosizeTextAreaProps = {
maxHeight?: number;
minHeight?: number;
maxHeight?: number;
minHeight?: number;
} & TextareaHTMLAttributes<HTMLTextAreaElement>;
export const AutosizeTextarea = forwardRef<
AutosizeTextAreaRef,
AutosizeTextAreaProps
AutosizeTextAreaRef,
AutosizeTextAreaProps
>(
(
{
maxHeight = Number.MAX_SAFE_INTEGER,
minHeight = 56,
className,
onChange,
value,
...props
}: AutosizeTextAreaProps,
ref: Ref<AutosizeTextAreaRef>,
) => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [triggerAutoSize, setTriggerAutoSize] = useState("");
(
{
maxHeight = Number.MAX_SAFE_INTEGER,
minHeight = 56,
className,
onChange,
value,
...props
}: AutosizeTextAreaProps,
ref: Ref<AutosizeTextAreaRef>,
) => {
const textAreaRef = useRef<HTMLTextAreaElement | null>(null);
const [triggerAutoSize, setTriggerAutoSize] = useState("");
useAutosizeTextArea({
textAreaRef: textAreaRef.current,
triggerAutoSize: triggerAutoSize,
maxHeight,
minHeight,
});
useAutosizeTextArea({
textAreaRef: textAreaRef.current,
triggerAutoSize: triggerAutoSize,
maxHeight,
minHeight,
});
useImperativeHandle(ref, () => ({
textArea: textAreaRef.current!,
focus: () => textAreaRef.current?.focus(),
maxHeight,
minHeight,
}));
useImperativeHandle(ref, () => ({
// biome-ignore lint/style/noNonNullAssertion: <it exists>
textArea: textAreaRef.current!,
focus: () => textAreaRef.current?.focus(),
maxHeight,
minHeight,
}));
useEffect(() => {
if (value || props?.defaultValue) {
setTriggerAutoSize(value as string);
}
}, [value || props?.defaultValue]);
useEffect(() => {
if (value || props?.defaultValue) {
setTriggerAutoSize(value as string);
}
}, [value || props?.defaultValue]);
return (
<textarea
{...props}
value={value}
ref={textAreaRef}
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",
className,
)}
onChange={(e) => {
setTriggerAutoSize(e.target.value);
onChange?.(e);
}}
/>
);
},
return (
<textarea
{...props}
value={value}
ref={textAreaRef}
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",
className,
)}
onChange={(e) => {
setTriggerAutoSize(e.target.value);
onChange?.(e);
}}
/>
);
},
);
AutosizeTextarea.displayName = "AutosizeTextarea";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,16 +2,16 @@
// import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import {
type EmojiPickerListCategoryHeaderProps,
type EmojiPickerListEmojiProps,
type EmojiPickerListRowProps,
EmojiPicker as EmojiPickerPrimitive,
// type SkinTone,
// useActiveEmoji,
// useSkinTone,
type EmojiPickerListCategoryHeaderProps,
type EmojiPickerListEmojiProps,
type EmojiPickerListRowProps,
EmojiPicker as EmojiPickerPrimitive,
// type SkinTone,
// useActiveEmoji,
// useSkinTone,
} from "frimousse";
import { LoaderIcon, SearchIcon } from "lucide-react";
import * as React from "react";
import type * as React from "react";
// import { Button } from "~/components/ui/button";
// import {
@ -24,115 +24,115 @@ import { cn } from "~/lib/utils";
// from https://ui-x.junwen-k.dev/docs/components/emoji-picker
function EmojiPicker({
className,
...props
className,
...props
}: React.ComponentProps<typeof EmojiPickerPrimitive.Root>) {
return (
<EmojiPickerPrimitive.Root
data-slot="emoji-picker"
className={cn(
"bg-popover text-popover-foreground isolate flex h-full w-fit flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
return (
<EmojiPickerPrimitive.Root
data-slot="emoji-picker"
className={cn(
"bg-popover text-popover-foreground isolate flex h-full w-fit flex-col overflow-hidden rounded-md",
className,
)}
{...props}
/>
);
}
function EmojiPickerSearch({
className,
...props
className,
...props
}: React.ComponentProps<typeof EmojiPickerPrimitive.Search>) {
return (
<div
data-slot="emoji-picker-search-wrapper"
className={cn("flex h-10 items-center gap-2 border-b px-3", className)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<EmojiPickerPrimitive.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"
{...props}
/>
</div>
);
return (
<div
data-slot="emoji-picker-search-wrapper"
className={cn("flex h-10 items-center gap-2 border-b px-3", className)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<EmojiPickerPrimitive.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"
{...props}
/>
</div>
);
}
function EmojiPickerRow({ children, ...props }: EmojiPickerListRowProps) {
return (
<div data-slot="emoji-picker-row" className="scroll-my-1 px-1" {...props}>
{children}
</div>
);
return (
<div data-slot="emoji-picker-row" className="scroll-my-1 px-1" {...props}>
{children}
</div>
);
}
function EmojiPickerEmoji({
emoji,
className,
...props
emoji,
className,
...props
}: EmojiPickerListEmojiProps) {
return (
<button
data-slot="emoji-picker-emoji"
className={cn(
"data-[active]:bg-accent flex size-7 items-center justify-center rounded-xs text-base",
className,
)}
{...props}
>
{emoji.emoji}
</button>
);
return (
<button
data-slot="emoji-picker-emoji"
className={cn(
"data-[active]:bg-accent flex size-7 items-center justify-center rounded-xs text-base",
className,
)}
{...props}
>
{emoji.emoji}
</button>
);
}
function EmojiPickerCategoryHeader({
category,
...props
category,
...props
}: EmojiPickerListCategoryHeaderProps) {
return (
<div
data-slot="emoji-picker-category-header"
className="bg-popover text-muted-foreground px-3 pt-3.5 pb-2 text-xs leading-none"
{...props}
>
{category.label}
</div>
);
return (
<div
data-slot="emoji-picker-category-header"
className="bg-popover text-muted-foreground px-3 pt-3.5 pb-2 text-xs leading-none"
{...props}
>
{category.label}
</div>
);
}
function EmojiPickerContent({
className,
...props
className,
...props
}: React.ComponentProps<typeof EmojiPickerPrimitive.Viewport>) {
return (
<EmojiPickerPrimitive.Viewport
data-slot="emoji-picker-viewport"
className={cn("relative flex-1 outline-hidden", className)}
{...props}
>
<EmojiPickerPrimitive.Loading
data-slot="emoji-picker-loading"
className="text-muted-foreground absolute inset-0 flex items-center justify-center"
>
<LoaderIcon className="size-4 animate-spin" />
</EmojiPickerPrimitive.Loading>
<EmojiPickerPrimitive.Empty
data-slot="emoji-picker-empty"
className="text-muted-foreground absolute inset-0 flex items-center justify-center text-sm"
>
No emoji found.
</EmojiPickerPrimitive.Empty>
<EmojiPickerPrimitive.List
data-slot="emoji-picker-list"
className="pb-1 select-none"
components={{
Row: EmojiPickerRow,
Emoji: EmojiPickerEmoji,
CategoryHeader: EmojiPickerCategoryHeader,
}}
/>
</EmojiPickerPrimitive.Viewport>
);
return (
<EmojiPickerPrimitive.Viewport
data-slot="emoji-picker-viewport"
className={cn("relative flex-1 outline-hidden", className)}
{...props}
>
<EmojiPickerPrimitive.Loading
data-slot="emoji-picker-loading"
className="text-muted-foreground absolute inset-0 flex items-center justify-center"
>
<LoaderIcon className="size-4 animate-spin" />
</EmojiPickerPrimitive.Loading>
<EmojiPickerPrimitive.Empty
data-slot="emoji-picker-empty"
className="text-muted-foreground absolute inset-0 flex items-center justify-center text-sm"
>
No emoji found.
</EmojiPickerPrimitive.Empty>
<EmojiPickerPrimitive.List
data-slot="emoji-picker-list"
className="pb-1 select-none"
components={{
Row: EmojiPickerRow,
Emoji: EmojiPickerEmoji,
CategoryHeader: EmojiPickerCategoryHeader,
}}
/>
</EmojiPickerPrimitive.Viewport>
);
}
/*function EmojiPickerFooter({
@ -228,8 +228,8 @@ function EmojiPickerSkinToneSelector() {
}
*/
export {
EmojiPicker,
EmojiPickerSearch,
EmojiPickerContent,
//EmojiPickerFooter,
EmojiPicker,
EmojiPickerSearch,
EmojiPickerContent,
//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 { Slot } from "@radix-ui/react-slot";
import * as React from "react";
import {
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
Controller,
type ControllerProps,
type FieldPath,
type FieldValues,
FormProvider,
useFormContext,
} from "react-hook-form";
import { cn } from "src/lib/utils";
import { Label } from "src/components/ui/label";
import { cn } from "src/lib/utils";
import { env } from "~/env.mjs";
const Form = FormProvider;
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName;
name: TName;
};
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue,
{} as FormFieldContextValue,
);
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
);
};
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldContext = React.useContext(FormFieldContext);
const itemContext = React.useContext(FormItemContext);
const { getFieldState, formState } = useFormContext();
const fieldState = getFieldState(fieldContext.name, formState);
const fieldState = getFieldState(fieldContext.name, formState);
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>");
}
const { id } = itemContext;
const { id } = itemContext;
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
};
};
type FormItemContextValue = {
id: string;
id: string;
};
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue,
{} as FormItemContextValue,
);
const FormItem = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => {
const id = React.useId();
const id = React.useId();
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
return (
<FormItemContext.Provider value={{ id }}>
<div ref={ref} className={cn("space-y-2", className)} {...props} />
</FormItemContext.Provider>
);
});
FormItem.displayName = "FormItem";
const FormLabel = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
>(({ className, ...props }, ref) => {
const { error, formItemId } = useFormField();
const { error, formItemId } = useFormField();
return (
<Label
ref={ref}
className={cn(error && "text-red-500 dark:text-red-600", className)}
htmlFor={formItemId}
{...props}
/>
);
return (
<Label
ref={ref}
className={cn(error && "text-red-500 dark:text-red-600", className)}
htmlFor={formItemId}
{...props}
/>
);
});
FormLabel.displayName = "FormLabel";
const FormControl = React.forwardRef<
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
React.ElementRef<typeof Slot>,
React.ComponentPropsWithoutRef<typeof Slot>
>(({ ...props }, ref) => {
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
const { error, formItemId, formDescriptionId, formMessageId } =
useFormField();
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
return (
<Slot
ref={ref}
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
);
});
FormControl.displayName = "FormControl";
const FormDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => {
const { formDescriptionId } = useFormField();
const { formDescriptionId } = useFormField();
return (
<p
ref={ref}
id={formDescriptionId}
className={cn(
"text-sm text-neutral-500 dark:text-neutral-400",
className,
)}
{...props}
/>
);
return (
<p
ref={ref}
id={formDescriptionId}
className={cn(
"text-sm text-neutral-500 dark:text-neutral-400",
className,
)}
{...props}
/>
);
});
FormDescription.displayName = "FormDescription";
const FormMessage = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, children, ...props }, ref) => {
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
const { error, formMessageId } = useFormField();
const body = error ? String(error?.message) : children;
if (!body) {
return null;
}
if (!body) {
return null;
}
return (
<p
ref={ref}
id={formMessageId}
className={cn(
"text-sm font-medium text-red-500 dark:text-red-600",
className,
)}
{...props}
>
{body}
</p>
);
return (
<p
ref={ref}
id={formMessageId}
className={cn(
"text-sm font-medium text-red-500 dark:text-red-600",
className,
)}
{...props}
>
{body}
</p>
);
});
FormMessage.displayName = "FormMessage";
const FormDebug = React.forwardRef<
React.ElementType,
React.HTMLAttributes<HTMLDivElement>
React.ElementType,
React.HTMLAttributes<HTMLDivElement>
>(async () => {
if (env.NODE_ENV !== "development") {
return null;
}
const { control, formState } = useFormContext();
const devtools = await import(/* webpackIgnore: true */ "@hookform/devtools");
if (env.NODE_ENV !== "development") {
return null;
}
const { control, formState } = useFormContext();
const devtools = await import(/* webpackIgnore: true */ "@hookform/devtools");
if (Object.keys(formState.errors).length > 0) {
console.error("errors", formState.errors);
}
return <devtools.DevTool control={control} />;
if (Object.keys(formState.errors).length > 0) {
console.error("errors", formState.errors);
}
return <devtools.DevTool control={control} />;
});
FormDebug.displayName = "FormDebug";
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormDebug,
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
FormDebug,
};

View file

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

View file

@ -1,35 +1,35 @@
import {
forwardRef,
type ComponentPropsWithoutRef,
type ReactNode,
type ComponentPropsWithoutRef,
forwardRef,
type ReactNode,
} from "react";
import Input from "~/components/custom_ui/input";
import { cn } from "~/lib/utils";
type InputWIconProps = ComponentPropsWithoutRef<typeof Input> & {
iconPosition: "start" | "end";
children: ReactNode;
iconPosition: "start" | "end";
children: ReactNode;
};
const InputWIcon = forwardRef<HTMLInputElement, InputWIconProps>(
({ children, iconPosition, ...props }, ref) => {
return (
<div className="relative">
<Input
className={cn("peer", iconPosition == "start" ? "ps-9" : "pe-9")}
{...props}
ref={ref}
/>
<div
className={cn(
"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",
)}
>
{children}
</div>
</div>
);
},
({ children, iconPosition, ...props }, ref) => {
return (
<div className="relative">
<Input
className={cn("peer", iconPosition === "start" ? "ps-9" : "pe-9")}
{...props}
ref={ref}
/>
<div
className={cn(
"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",
)}
>
{children}
</div>
</div>
);
},
);
InputWIcon.displayName = "InputWIcon";

View file

@ -4,19 +4,19 @@ import { cn } from "src/lib/utils";
type InputProps = InputHTMLAttributes<HTMLInputElement>;
const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
ref={ref}
{...props}
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`,
className,
)}
/>
);
},
({ className, type, ...props }, ref) => {
return (
<input
type={type}
ref={ref}
{...props}
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`,
className,
)}
/>
);
},
);
Input.displayName = "Input";

View file

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

View file

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

View file

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

View file

@ -1,98 +1,97 @@
/* eslint-disable better-tailwindcss/no-unregistered-classes */
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef, useState, type JSX } from "react";
import { type JSX, useRef, useState } from "react";
import ReactSelect, {
type MenuListProps,
type GroupBase,
type CSSObjectWithLabel,
type CSSObjectWithLabel,
type GroupBase,
type MenuListProps,
} from "react-select";
import { cn } from "~/lib/utils";
export type ListOption = {
label: string;
value: string;
label: string;
value: string;
};
type BaseProps = {
elementId: string;
elementName: string;
options: ListOption[];
placeholder: string;
disabled?: boolean;
menuPlacement?: "auto" | "bottom" | "top";
defaultValue?: ListOption | ListOption[];
elementId: string;
elementName: string;
options: ListOption[];
placeholder: string;
disabled?: boolean;
menuPlacement?: "auto" | "bottom" | "top";
defaultValue?: ListOption | ListOption[];
};
type MultiProps = {
isMulti: true;
onValueChange: (value: ListOption[]) => void;
isMulti: true;
onValueChange: (value: ListOption[]) => void;
};
type SigletProps = {
isMulti: false;
onValueChange: (value: ListOption) => void;
isMulti: false;
onValueChange: (value: ListOption) => void;
};
type MultiSelectProps = BaseProps & (MultiProps | SigletProps);
const VirtualizedMenuList = (
props: MenuListProps<
ListOption,
MultiSelectProps["isMulti"],
GroupBase<ListOption>
>,
props: MenuListProps<
ListOption,
MultiSelectProps["isMulti"],
GroupBase<ListOption>
>,
) => {
const rows = props.children as JSX.Element[];
const parentRef = useRef<HTMLDivElement>(null);
const count = rows.length;
const rows = props.children as JSX.Element[];
const parentRef = useRef<HTMLDivElement>(null);
const count = rows.length;
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => 45,
});
const items = virtualizer.getVirtualItems();
const virtualizer = useVirtualizer({
count,
getScrollElement: () => parentRef.current,
estimateSize: () => 45,
});
const items = virtualizer.getVirtualItems();
return (
<div
ref={parentRef}
className="List"
style={{
height: `${virtualizer.getTotalSize()}px`,
maxHeight: "300px",
width: "auto",
overflowY: "auto",
contain: "strict",
}}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${items[0]?.start ?? 0}px)`,
}}
>
{items.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={virtualRow.index % 2 ? "ListItemOdd" : "ListItemEven"}
>
{rows[virtualRow.index]}
</div>
))}
</div>
</div>
</div>
);
return (
<div
ref={parentRef}
className="List"
style={{
height: `${virtualizer.getTotalSize()}px`,
maxHeight: "300px",
width: "auto",
overflowY: "auto",
contain: "strict",
}}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
<div
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${items[0]?.start ?? 0}px)`,
}}
>
{items.map((virtualRow) => (
<div
key={virtualRow.key}
data-index={virtualRow.index}
ref={virtualizer.measureElement}
className={virtualRow.index % 2 ? "ListItemOdd" : "ListItemEven"}
>
{rows[virtualRow.index]}
</div>
))}
</div>
</div>
</div>
);
};
/**
@ -116,178 +115,180 @@ const VirtualizedMenuList = (
* />
*/
export const MultiSelect = ({
elementId,
defaultValue,
elementName,
isMulti,
onValueChange,
options,
placeholder,
disabled,
menuPlacement,
elementId,
defaultValue,
elementName,
isMulti,
onValueChange,
options,
placeholder,
disabled,
menuPlacement,
}: MultiSelectProps) => {
const [menuOpen, setMenuOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
return (
<ReactSelect
menuPlacement={menuPlacement}
components={{ MenuList: VirtualizedMenuList }}
inputId={elementId}
isMulti={isMulti}
isDisabled={disabled}
instanceId={elementId}
name={elementName}
options={options}
onChange={(values) => {
if (isMulti) {
const valore = values as ListOption[];
onValueChange(valore);
} else {
const valore = values as ListOption;
onValueChange(valore);
}
}}
defaultValue={defaultValue}
placeholder={placeholder}
onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => setMenuOpen(false)}
styles={{
container: (base, state) =>
({
...base,
return (
<ReactSelect
menuPlacement={menuPlacement}
components={{ MenuList: VirtualizedMenuList }}
inputId={elementId}
isMulti={isMulti}
isDisabled={disabled}
instanceId={elementId}
name={elementName}
options={options}
onChange={(values) => {
if (isMulti) {
const valore = values as ListOption[];
onValueChange(valore);
} else {
const valore = values as ListOption;
onValueChange(valore);
}
}}
defaultValue={defaultValue}
placeholder={placeholder}
onMenuOpen={() => setMenuOpen(true)}
onMenuClose={() => setMenuOpen(false)}
styles={{
container: (base, state) =>
({
...base,
":hover .select__indicator-separator": {
backgroundColor: state.isFocused
? "rgb(161,161,170)"
: "rgb(161,161,170)",
},
":hover .select__indicator-separator": {
backgroundColor: state.isFocused
? "rgb(161,161,170)"
: "rgb(161,161,170)",
},
":hover .select__dropdown-indicator": {
color: state.isFocused ? "rgb(161,161,170)" : "rgb(161,161,170)",
},
}) as CSSObjectWithLabel,
control: (base, state) =>
({
...base,
minHeight: "42px",
borderRadius: "0.5rem",
boxShadow: undefined,
":hover .select__dropdown-indicator": {
color: state.isFocused ? "rgb(161,161,170)" : "rgb(161,161,170)",
},
}) as CSSObjectWithLabel,
control: (base, state) =>
({
...base,
minHeight: "42px",
borderRadius: "0.5rem",
boxShadow: undefined,
border: state.isFocused
? "1px solid rgb(161,161,170)"
: "1px solid rgb(212 ,212 ,216)",
border: state.isFocused
? "1px solid rgb(161,161,170)"
: "1px solid rgb(212 ,212 ,216)",
"&:hover": {
borderColor: state.isFocused
? "rgb(161,161,170)"
: "rgb(161 ,161 ,170)",
},
"&:hover": {
borderColor: state.isFocused
? "rgb(161,161,170)"
: "rgb(161 ,161 ,170)",
},
"& .select__indicator-separator": {
backgroundColor: state.isFocused
? "rgb(161,161,170)"
: "rgb(212 ,212 ,216)",
},
"& .select__indicator-separator": {
backgroundColor: state.isFocused
? "rgb(161,161,170)"
: "rgb(212 ,212 ,216)",
},
"& .select__dropdown-indicator": {
color: state.isFocused
? "rgb(161,161,170)"
: "rgb(212 ,212 ,216)",
transition: "none",
},
}) as CSSObjectWithLabel,
multiValue: (base) =>
({
...base,
backgroundColor: undefined,
borderRadius: "0.5rem",
}) as CSSObjectWithLabel,
multiValueLabel: (base) =>
({
...base,
borderRadius: "0.5rem",
}) as CSSObjectWithLabel,
menu: (base) =>
({
...base,
borderRadius: "0.5rem",
}) as CSSObjectWithLabel,
option: (base, state) =>
({
...base,
backgroundColor: undefined,
color: state.isSelected ? undefined : undefined,
"& .select__dropdown-indicator": {
color: state.isFocused
? "rgb(161,161,170)"
: "rgb(212 ,212 ,216)",
transition: "none",
},
}) as CSSObjectWithLabel,
multiValue: (base) =>
({
...base,
backgroundColor: undefined,
borderRadius: "0.5rem",
}) as CSSObjectWithLabel,
multiValueLabel: (base) =>
({
...base,
borderRadius: "0.5rem",
}) as CSSObjectWithLabel,
menu: (base) =>
({
...base,
borderRadius: "0.5rem",
}) as CSSObjectWithLabel,
option: (base, state) =>
({
...base,
backgroundColor: undefined,
color: state.isSelected ? undefined : undefined,
"&:hover": {
backgroundColor: undefined,
color: state.isSelected ? undefined : undefined,
},
"&:hover": {
backgroundColor: undefined,
color: state.isSelected ? undefined : undefined,
},
"&:active": {
backgroundColor: "rgb(161, 161, 170)",
color: "rgb(255, 255, 255)",
},
"&:active": {
backgroundColor: "rgb(161, 161, 170)",
color: "rgb(255, 255, 255)",
},
"&:focus": {
backgroundColor: undefined,
},
}) as CSSObjectWithLabel,
}}
classNames={{
valueContainer: () => cn("min-h-[38px]"),
control: () =>
cn(
"mt-2 w-full rounded-lg h-min-[42px] dark:bg-primary bg-white shadow-xs outline-hidden text-foreground dark:text-white",
),
option: () =>
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",
),
menu: () =>
cn(
`dark:bg-black text-foreground bg-white dark:text-white border shadow-xs outline-hidden
"&:focus": {
backgroundColor: undefined,
},
}) as CSSObjectWithLabel,
}}
classNames={{
valueContainer: () => cn("min-h-[38px]"),
control: () =>
cn(
"mt-2 w-full rounded-lg h-min-[42px] dark:bg-primary bg-white shadow-xs outline-hidden text-foreground dark:text-white",
),
option: () =>
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",
),
menu: () =>
cn(
`dark:bg-black text-foreground bg-white dark:text-white border shadow-xs outline-hidden
dark:border-foreground border-neutral-300 overflow-hidden`,
menuOpen
? "animate-in fade-in-0 zoom-in-90"
: "animate-out fade-out-0 zoom-out-90",
),
multiValueLabel: () =>
cn(
"dark:bg-black rounded-lg text-primary bg-white dark:text-white outline-hidden",
),
multiValue: () =>
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",
),
multiValueRemove: () => cn("rounded-s-none rounded-e-md"),
input: () => cn("*:ring-0 focus:*:ring-0"),
singleValue: () => cn("text-primary font-medium"),
}}
classNamePrefix="select"
/>
);
menuOpen
? "animate-in fade-in-0 zoom-in-90"
: "animate-out fade-out-0 zoom-out-90",
),
multiValueLabel: () =>
cn(
"dark:bg-black rounded-lg text-primary bg-white dark:text-white outline-hidden",
),
multiValue: () =>
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",
),
multiValueRemove: () => cn("rounded-s-none rounded-e-md"),
input: () => cn("*:ring-0 focus:*:ring-0"),
singleValue: () => cn("text-primary font-medium"),
}}
classNamePrefix="select"
/>
);
};
type UnpackOptions = {
options: string[];
options: string[];
};
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 = {
values: string | string[];
options: string[];
values: string | string[];
options: string[];
};
export const RepackValues = ({ values, options }: RepackValues) => {
if (Array.isArray(values)) {
return values.map((v) => ({
label: options[Number.parseInt(v)]!,
value: v,
}));
}
return {
label: options[Number.parseInt(values)]!,
value: values,
};
if (Array.isArray(values)) {
return values.map((v) => ({
// biome-ignore lint/style/noNonNullAssertion: <intended>
label: options[Number.parseInt(v)]!,
value: v,
}));
}
return {
// 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 { X } from "lucide-react";
import {
createContext,
forwardRef,
useContext,
useEffect,
useRef,
useState,
type ComponentPropsWithoutRef,
type Dispatch,
type ElementRef,
type HTMLAttributes,
type PointerEvent,
type ReactNode,
type SetStateAction,
type ComponentPropsWithoutRef,
createContext,
type Dispatch,
type ElementRef,
forwardRef,
type HTMLAttributes,
type PointerEvent,
type ReactNode,
type SetStateAction,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { cn } from "~/lib/utils";
interface DialogContextValue {
innerOpen: boolean;
setInnerOpen: Dispatch<SetStateAction<boolean>>;
innerOpen: boolean;
setInnerOpen: Dispatch<SetStateAction<boolean>>;
}
const DialogContext = createContext<DialogContextValue | undefined>(undefined);
function Dialog({ children }: { children: ReactNode }) {
const [outerOpen, setOuterOpen] = useState(false);
const [innerOpen, setInnerOpen] = useState(false);
const [outerOpen, setOuterOpen] = useState(false);
const [innerOpen, setInnerOpen] = useState(false);
return (
<DialogContext.Provider value={{ innerOpen, setInnerOpen }}>
<DialogPrimitive.Root open={outerOpen} onOpenChange={setOuterOpen}>
{children}
</DialogPrimitive.Root>
</DialogContext.Provider>
);
return (
<DialogContext.Provider value={{ innerOpen, setInnerOpen }}>
<DialogPrimitive.Root open={outerOpen} onOpenChange={setOuterOpen}>
{children}
</DialogPrimitive.Root>
</DialogContext.Provider>
);
}
const DialogTrigger = DialogPrimitive.Trigger;
@ -44,301 +44,301 @@ const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = forwardRef<
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
ElementRef<typeof DialogPrimitive.Overlay>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
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",
className,
)}
{...props}
/>
<DialogPrimitive.Overlay
ref={ref}
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",
className,
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
ElementRef<typeof DialogPrimitive.Content>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => {
const context = useContext(DialogContext);
if (!context) throw new Error("DialogContent must be used within a Dialog");
const context = useContext(DialogContext);
if (!context) throw new Error("DialogContent must be used within a Dialog");
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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",
context.innerOpen && "translate-y-[-55%] scale-[0.97]",
className,
)}
{...props}
>
{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">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</DialogPrimitive.Content>
</DialogPortal>
);
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
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",
context.innerOpen && "translate-y-[-55%] scale-[0.97]",
className,
)}
{...props}
>
{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">
<X className="size-4" />
<span className="sr-only">Close</span>
</DialogClose>
</DialogPrimitive.Content>
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
function InnerDialog({ children }: { children: ReactNode }) {
const context = useContext(DialogContext);
if (!context) throw new Error("InnerDialog must be used within a Dialog");
const context = useContext(DialogContext);
if (!context) throw new Error("InnerDialog must be used within a Dialog");
useEffect(() => {
const handleEscapeKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && context.innerOpen) {
context.setInnerOpen(false);
event.stopPropagation();
}
};
useEffect(() => {
const handleEscapeKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && context.innerOpen) {
context.setInnerOpen(false);
event.stopPropagation();
}
};
document.addEventListener("keydown", handleEscapeKeyDown);
return () => {
document.removeEventListener("keydown", handleEscapeKeyDown);
};
}, [context.innerOpen, context.setInnerOpen]);
document.addEventListener("keydown", handleEscapeKeyDown);
return () => {
document.removeEventListener("keydown", handleEscapeKeyDown);
};
}, [context.innerOpen, context.setInnerOpen]);
return (
<DialogPrimitive.Root
open={context.innerOpen}
onOpenChange={context.setInnerOpen}
>
{children}
</DialogPrimitive.Root>
);
return (
<DialogPrimitive.Root
open={context.innerOpen}
onOpenChange={context.setInnerOpen}
>
{children}
</DialogPrimitive.Root>
);
}
const InnerDialogTrigger = DialogPrimitive.Trigger;
const InnerDialogClose = DialogPrimitive.Close;
interface InnerDialogContentProps
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
position?: "default" | "bottom" | "top" | "left" | "right";
draggable?: boolean;
extends ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {
position?: "default" | "bottom" | "top" | "left" | "right";
draggable?: boolean;
}
const InnerDialogContent = forwardRef<
ElementRef<typeof DialogPrimitive.Content>,
InnerDialogContentProps
ElementRef<typeof DialogPrimitive.Content>,
InnerDialogContentProps
>(
(
{ className, children, position = "default", draggable = false, ...props },
ref,
) => {
const context = useContext(DialogContext);
if (!context)
throw new Error("InnerDialogContent must be used within a Dialog");
(
{ className, children, position = "default", draggable = false, ...props },
ref,
) => {
const context = useContext(DialogContext);
if (!context)
throw new Error("InnerDialogContent must be used within a Dialog");
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
const [currentY, setCurrentY] = useState(0);
const [isClosingByDrag, setIsClosingByDrag] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [startY, setStartY] = useState(0);
const [currentY, setCurrentY] = useState(0);
const [isClosingByDrag, setIsClosingByDrag] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (context.innerOpen) {
setCurrentY(0);
setIsClosingByDrag(false);
}
}, [context.innerOpen]);
useEffect(() => {
if (context.innerOpen) {
setCurrentY(0);
setIsClosingByDrag(false);
}
}, [context.innerOpen]);
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (!draggable) return;
setIsDragging(true);
setStartY(e.clientY - currentY);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
if (!draggable) return;
setIsDragging(true);
setStartY(e.clientY - currentY);
e.currentTarget.setPointerCapture(e.pointerId);
};
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!isDragging || !draggable) return;
const newY = e.clientY - startY;
setCurrentY(newY > 0 ? newY : 0);
};
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
if (!isDragging || !draggable) return;
const newY = e.clientY - startY;
setCurrentY(newY > 0 ? newY : 0);
};
const handlePointerUp = () => {
if (!draggable) return;
setIsDragging(false);
if (currentY > (contentRef.current?.offsetHeight || 0) / 2) {
setIsClosingByDrag(true);
context.setInnerOpen(false);
} else {
setCurrentY(0);
}
};
const handlePointerUp = () => {
if (!draggable) return;
setIsDragging(false);
if (currentY > (contentRef.current?.offsetHeight || 0) / 2) {
setIsClosingByDrag(true);
context.setInnerOpen(false);
} else {
setCurrentY(0);
}
};
return (
<DialogPortal>
<DialogPrimitive.Content
ref={ref}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
transform: `translate(-50%, calc(-50% + ${currentY}px))`,
transition: isDragging ? "none" : "transform 0.3s ease-out",
}}
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",
isClosingByDrag
? "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%]",
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%]",
position === "bottom" &&
"data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-bottom-full",
position === "top" &&
"data-[state=closed]:slide-out-to-top-full data-[state=open]:slide-in-from-top-full",
position === "left" &&
"data-[state=closed]:slide-out-to-left-full data-[state=open]:slide-in-from-left-full",
position === "right" &&
"data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
draggable && "",
className,
)}
{...props}
>
<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">
<X className="size-4" />
<span className="sr-only">Close</span>
</InnerDialogClose>
</DialogPrimitive.Content>
</DialogPortal>
);
},
return (
<DialogPortal>
<DialogPrimitive.Content
ref={ref}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
style={{
transform: `translate(-50%, calc(-50% + ${currentY}px))`,
transition: isDragging ? "none" : "transform 0.3s ease-out",
}}
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",
isClosingByDrag
? "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%]",
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%]",
position === "bottom" &&
"data-[state=closed]:slide-out-to-bottom-full data-[state=open]:slide-in-from-bottom-full",
position === "top" &&
"data-[state=closed]:slide-out-to-top-full data-[state=open]:slide-in-from-top-full",
position === "left" &&
"data-[state=closed]:slide-out-to-left-full data-[state=open]:slide-in-from-left-full",
position === "right" &&
"data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-right-full",
draggable && "",
className,
)}
{...props}
>
<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">
<X className="size-4" />
<span className="sr-only">Close</span>
</InnerDialogClose>
</DialogPrimitive.Content>
</DialogPortal>
);
},
);
InnerDialogContent.displayName = "InnerDialogContent";
const InnerDialogHeader = ({
className,
...props
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
InnerDialogHeader.displayName = "InnerDialogHeader";
const InnerDialogFooter = ({
className,
...props
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
{...props}
/>
<div
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
{...props}
/>
);
InnerDialogFooter.displayName = "InnerDialogFooter";
const InnerDialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...props}
/>
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...props}
/>
));
InnerDialogTitle.displayName = "InnerDialogTitle";
const InnerDialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
InnerDialogDescription.displayName = "InnerDialogDescription";
const DialogHeader = ({
className,
...props
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className,
)}
{...props}
/>
);
DialogHeader.displayName = "DialogHeader";
const DialogFooter = ({
className,
...props
className,
...props
}: HTMLAttributes<HTMLDivElement>) => (
<div
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
{...props}
/>
<div
className={cn("flex flex-col-reverse sm:flex-row sm:space-x-2", className)}
{...props}
/>
);
DialogFooter.displayName = "DialogFooter";
const DialogTitle = forwardRef<
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
ElementRef<typeof DialogPrimitive.Title>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...props}
/>
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg leading-none font-semibold tracking-tight",
className,
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = forwardRef<
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
ElementRef<typeof DialogPrimitive.Description>,
ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
<DialogPrimitive.Description
ref={ref}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export type { InnerDialogContentProps };
export {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
InnerDialog,
InnerDialogTrigger,
InnerDialogContent,
InnerDialogHeader,
InnerDialogFooter,
InnerDialogTitle,
InnerDialogDescription,
InnerDialogClose,
DialogPortal,
DialogOverlay,
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
DialogClose,
InnerDialog,
InnerDialogTrigger,
InnerDialogContent,
InnerDialogHeader,
InnerDialogFooter,
InnerDialogTitle,
InnerDialogDescription,
InnerDialogClose,
DialogPortal,
DialogOverlay,
};

View file

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

View file

@ -4,18 +4,18 @@ import { cn } from "src/lib/utils";
type TextareaProps = TextareaHTMLAttributes<HTMLTextAreaElement>;
const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
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`,
className,
)}
ref={ref}
{...props}
/>
);
},
({ className, ...props }, ref) => {
return (
<textarea
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`,
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = "Textarea";

View file

@ -2,17 +2,17 @@ import { MissingFile } from "~/components/svgs";
import { useTranslation } from "~/providers/I18nProvider";
export const DocNotFoundPage = () => {
const { t } = useTranslation();
return (
<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">
<MissingFile className="mx-auto mb-4 size-32 fill-indigo-600" />
<h1 className="mb-2 text-3xl font-extrabold text-indigo-600">
{t.doc_not_found.titolo}
</h1>
<p className="text-muted-foreground">{t.doc_not_found.sottotitolo}</p>
<p className="text-muted-foreground">{t.doc_not_found.CTA}</p>
</div>
</div>
);
const { t } = useTranslation();
return (
<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">
<MissingFile className="mx-auto mb-4 size-32 fill-indigo-600" />
<h1 className="mb-2 text-3xl font-extrabold text-indigo-600">
{t.doc_not_found.titolo}
</h1>
<p className="text-muted-foreground">{t.doc_not_found.sottotitolo}</p>
<p className="text-muted-foreground">{t.doc_not_found.CTA}</p>
</div>
</div>
);
};

View file

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

View file

@ -1,38 +1,39 @@
import Link from "next/link";
import { useRouter } from "next/router";
import { useTranslation } from "~/providers/I18nProvider";
import { HomeIcon } from "~/components/IconComponents";
import { useTranslation } from "~/providers/I18nProvider";
export default function FailedAnnuncioLoading() {
const router = useRouter();
const { t } = useTranslation();
return (
<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 max-w-lg space-y-3 text-center">
<HomeIcon className="mx-auto size-20 text-rose-700" />
<h3 className="text-accent-foreground text-4xl font-semibold sm:text-5xl">
{t.annuncio_load_fail.titolo}
</h3>
<p className="text-neutral-600">{t.annuncio_load_fail.sottotitolo}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<button
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"
>
{t.annuncio_load_fail.CTA}
</button>
<Link
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>
</div>
</div>
</div>
</main>
);
const router = useRouter();
const { t } = useTranslation();
return (
<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 max-w-lg space-y-3 text-center">
<HomeIcon className="mx-auto size-20 text-rose-700" />
<h3 className="text-accent-foreground text-4xl font-semibold sm:text-5xl">
{t.annuncio_load_fail.titolo}
</h3>
<p className="text-neutral-600">{t.annuncio_load_fail.sottotitolo}</p>
<div className="flex flex-wrap items-center justify-center gap-3">
<button
type="button"
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"
>
{t.annuncio_load_fail.CTA}
</button>
<Link
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>
</div>
</div>
</div>
</main>
);
}

View file

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

View file

@ -2,10 +2,10 @@
import { ASvg } from "~/components/svgs";
export const LoadingPage = () => {
return (
<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>
<ASvg className="absolute size-24 -translate-y-2" />
</div>
);
return (
<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>
<ASvg className="absolute size-24 -translate-y-2" />
</div>
);
};

View file

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

View file

@ -1,49 +1,49 @@
import {
useState,
useId,
useMemo,
type ReactElement,
useCallback,
isValidElement,
type ComponentType,
useLayoutEffect,
forwardRef,
useEffect,
type DivIconOptions,
DomEvent,
divIcon,
type LeafletEventHandlerFn,
type LeafletEventHandlerFnMap,
type Marker as LeafletMarker,
type PointExpression,
type PointTuple,
} from "leaflet";
import {
type ComponentType,
forwardRef,
isValidElement,
type ReactElement,
useCallback,
useEffect,
useId,
useLayoutEffect,
useMemo,
useState,
} from "react";
import { createPortal } from "react-dom";
import { isValidElementType } from "react-is";
import {
Marker as ReactLeafletMarker,
type MarkerProps as ReactLeafletMarkerProps,
Marker as ReactLeafletMarker,
type MarkerProps as ReactLeafletMarkerProps,
} from "react-leaflet";
import {
divIcon,
DomEvent,
type LeafletEventHandlerFn,
type LeafletEventHandlerFnMap,
type PointExpression,
type DivIconOptions,
type Marker as LeafletMarker,
type PointTuple,
} from "leaflet";
import { type SetRequired } from "type-fest";
import {
createHtmlPortalNode,
OutPortal,
InPortal,
createHtmlPortalNode,
InPortal,
OutPortal,
} from "react-reverse-portal";
import type { SetRequired } from "type-fest";
const getCoordsFromPointExpression = (expression?: PointExpression) => {
if (!expression) return [];
if (Array.isArray(expression)) {
return expression;
} else {
return [expression.x, expression.y] as PointTuple;
}
if (!expression) return [];
if (Array.isArray(expression)) {
return expression;
} else {
return [expression.x, expression.y] as PointTuple;
}
};
const useCoordsFromPointExpression = (expression?: PointExpression) =>
useMemo(() => getCoordsFromPointExpression(expression), [expression]);
useMemo(() => getCoordsFromPointExpression(expression), [expression]);
/**
* 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 ComponentMarkerRootDivIconOpts = Omit<
DivIconOptions,
| "html"
| "bgPos"
| "shadowUrl"
| "shadowSize"
| "shadowAnchor"
| "shadowRetinaUrl"
| "iconUrl"
| "iconRetinaUrl"
DivIconOptions,
| "html"
| "bgPos"
| "shadowUrl"
| "shadowSize"
| "shadowAnchor"
| "shadowRetinaUrl"
| "iconUrl"
| "iconRetinaUrl"
>;
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.
*
* 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}
**/
rootDivOpts: SetRequired<ComponentMarkerRootDivIconOpts, RequiredKeys>;
/**
* 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.
*
* @see {@link ComponentMarkerRootDivIconOpts}
**/
rootDivOpts: SetRequired<ComponentMarkerRootDivIconOpts, RequiredKeys>;
};
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
* 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.
*
* @defaultValue `"fit-content"`
* @see {@link ComponentMarkerLayout}
*/
layoutMode?: ComponentMarkerLayout;
/**
* `'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.
*
* `'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"`
* @see {@link ComponentMarkerLayout}
*/
layoutMode?: ComponentMarkerLayout;
/**
* If set to `true`, panning/scrolling the map will not be possible "through" the component marker.
*
* This applies to the entire component marker.
*
* @defaultValue `false`
*/
disableScrollPropagation?: boolean;
/**
* If set to `true`, panning/scrolling the map will not be possible "through" the component marker.
*
* This applies to the entire component marker.
*
* @defaultValue `false`
*/
disableScrollPropagation?: boolean;
/**
* 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
* popups via clicking.
*
* @defaultValue `false`
*/
disableClickPropagation?: boolean;
/**
* 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
* popups via clicking.
*
* @defaultValue `false`
*/
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
* is not a component. This would mean those options are unused.
*
* @defaultValue `true`
* @see {@link BaseMarkerProps.componentIconOpts}
*/
unusedOptsWarning?: 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
* is not a component. This would mean those options are unused.
*
* @defaultValue `true`
* @see {@link BaseMarkerProps.componentIconOpts}
*/
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`}
* has not been set. This would mean the size of the React component icon would not be visible.
*
* @defaultValue `true`
* @see {@link ComponentMarkerOpts.rootDivOpts}
* @see {@link ComponentMarkerRootDivIconOpts.iconSize}
*/
rootSizeWarning?: 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`}
* has not been set. This would mean the size of the React component icon would not be visible.
*
* @defaultValue `true`
* @see {@link ComponentMarkerOpts.rootDivOpts}
* @see {@link ComponentMarkerRootDivIconOpts.iconSize}
*/
rootSizeWarning?: boolean;
};
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
? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, undefined>>
: never;
? T & Partial<Record<Exclude<UnionKeys<TAll>, keyof T>, undefined>>
: never;
type StrictUnion<T> = StrictUnionHelper<T, T>;
type ComponentMarkerOpts = StrictUnion<
| (CoreComponentMarkerOpts & {
layoutMode?: "fit-content";
} & Partial<RootDivOpt>)
| (CoreComponentMarkerOpts & {
layoutMode: "fit-parent";
} & RootDivOpt<"iconSize">)
| (CoreComponentMarkerOpts & {
layoutMode?: "fit-content";
} & Partial<RootDivOpt>)
| (CoreComponentMarkerOpts & {
layoutMode: "fit-parent";
} & RootDivOpt<"iconSize">)
>;
type BaseMarkerProps<AdditionalIconTypes = never> = Omit<
ReactLeafletMarkerProps,
"icon"
ReactLeafletMarkerProps,
"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. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: ReactElement<any> | AdditionalIconTypes;
/** A {@link ReactElement} representing the Markers icon, or any type from [react-leaflet Marker](https://react-leaflet.js.org/docs/api-components/#marker) component. */
/**
* 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.
*
* @see {@link ComponentMarkerOpts.unusedOptsWarning}
*/
componentIconOpts?: ComponentMarkerOpts;
// biome-ignore lint/suspicious/noExplicitAny: <intended>
icon: ReactElement<any> | AdditionalIconTypes;
/**
* 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.
*
* @see {@link ComponentMarkerOpts.unusedOptsWarning}
*/
componentIconOpts?: ComponentMarkerOpts;
};
type MarkerProps = BaseMarkerProps<
ReactLeafletMarkerProps["icon"] | ComponentType
ReactLeafletMarkerProps["icon"] | ComponentType
>;
const DEFAULT_ICON_SIZE: PointExpression = [0, 0];
const ComponentMarker = forwardRef<LeafletMarker, BaseMarkerProps>(
(
{
eventHandlers: providedEventHandlers,
icon: providedIcon,
(
{
eventHandlers: providedEventHandlers,
icon: providedIcon,
componentIconOpts: {
layoutMode = "fit-content",
disableClickPropagation,
disableScrollPropagation,
rootDivOpts,
rootSizeWarning,
} = {},
...otherProps
},
ref,
) => {
const [markerRendered, setMarkerRendered] = useState(false);
const [, setChangeCount] = useState(0);
const id = "marker-" + useId();
componentIconOpts: {
layoutMode = "fit-content",
disableClickPropagation,
disableScrollPropagation,
rootDivOpts,
rootSizeWarning,
} = {},
...otherProps
},
ref,
) => {
const [markerRendered, setMarkerRendered] = useState(false);
const [, setChangeCount] = useState(0);
const id = `marker-${useId()}`;
const portalNode = useMemo(
() =>
createHtmlPortalNode({
attributes: {
"data-react-component-marker": "portal-parent",
style: "width:100%;height:100%;",
},
}),
[],
);
const portalNode = useMemo(
() =>
createHtmlPortalNode({
attributes: {
"data-react-component-marker": "portal-parent",
style: "width:100%;height:100%;",
},
}),
[],
);
useEffect(() => {
if (
rootSizeWarning !== false &&
layoutMode === "fit-parent" &&
rootDivOpts?.iconSize === undefined
) {
console.warn(
`UNBOUND_FIT_PARENT
useEffect(() => {
if (
rootSizeWarning !== false &&
layoutMode === "fit-parent" &&
rootDivOpts?.iconSize === undefined
) {
console.warn(
`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.
To disable this warning set 'componentIconOpts.rootSizeWarning' to false.`,
);
}
}, [layoutMode, rootSizeWarning, rootDivOpts?.iconSize]);
);
}
}, [layoutMode, rootSizeWarning, rootDivOpts?.iconSize]);
const {
attribution,
className,
iconAnchor,
iconSize = DEFAULT_ICON_SIZE,
pane,
popupAnchor,
tooltipAnchor,
} = rootDivOpts ?? {};
const {
attribution,
className,
iconAnchor,
iconSize = DEFAULT_ICON_SIZE,
pane,
popupAnchor,
tooltipAnchor,
} = rootDivOpts ?? {};
const iconDeps = [
id,
layoutMode,
attribution,
className,
pane,
Boolean(disableClickPropagation),
Boolean(disableScrollPropagation),
...useCoordsFromPointExpression(iconSize),
...useCoordsFromPointExpression(iconAnchor),
...useCoordsFromPointExpression(popupAnchor),
...useCoordsFromPointExpression(tooltipAnchor),
];
const iconDeps = [
id,
layoutMode,
attribution,
className,
pane,
Boolean(disableClickPropagation),
Boolean(disableScrollPropagation),
...useCoordsFromPointExpression(iconSize),
...useCoordsFromPointExpression(iconAnchor),
...useCoordsFromPointExpression(popupAnchor),
...useCoordsFromPointExpression(tooltipAnchor),
];
const icon = useMemo(() => {
const parentStyles =
layoutMode === "fit-content"
? "width: min-content; transform: translate(-50%, -50%)"
: "width: 100%; height: 100%";
const icon = useMemo(() => {
const parentStyles =
layoutMode === "fit-content"
? "width: min-content; transform: translate(-50%, -50%)"
: "width: 100%; height: 100%";
return divIcon({
html: `<div data-react-component-marker="root" style="${parentStyles}" id="${id}"></div>`,
...(iconSize ? { iconSize } : []),
...(iconAnchor ? { iconAnchor } : []),
...(popupAnchor ? { popupAnchor } : []),
...(tooltipAnchor ? { tooltipAnchor } : []),
pane,
attribution,
className,
});
}, iconDeps);
return divIcon({
html: `<div data-react-component-marker="root" style="${parentStyles}" id="${id}"></div>`,
...(iconSize ? { iconSize } : []),
...(iconAnchor ? { iconAnchor } : []),
...(popupAnchor ? { popupAnchor } : []),
...(tooltipAnchor ? { tooltipAnchor } : []),
pane,
attribution,
className,
});
}, iconDeps);
useLayoutEffect(() => {
setChangeCount((prev) => prev + 1);
}, [icon]);
useLayoutEffect(() => {
setChangeCount((prev) => prev + 1);
}, [icon]);
const handleAddEvent = useCallback<LeafletEventHandlerFn>(
(...args) => {
setMarkerRendered(true);
if (providedEventHandlers?.add) providedEventHandlers.add(...args);
},
[providedEventHandlers?.add],
);
const handleAddEvent = useCallback<LeafletEventHandlerFn>(
(...args) => {
setMarkerRendered(true);
if (providedEventHandlers?.add) providedEventHandlers.add(...args);
},
[providedEventHandlers?.add],
);
const handleRemoveEvent = useCallback<LeafletEventHandlerFn>(
(...args) => {
setMarkerRendered(false);
if (providedEventHandlers?.remove)
providedEventHandlers.remove(...args);
},
[providedEventHandlers?.remove],
);
const handleRemoveEvent = useCallback<LeafletEventHandlerFn>(
(...args) => {
setMarkerRendered(false);
if (providedEventHandlers?.remove)
providedEventHandlers.remove(...args);
},
[providedEventHandlers?.remove],
);
const eventHandlers = useMemo<LeafletEventHandlerFnMap>(
() => ({
...providedEventHandlers,
add: handleAddEvent,
remove: handleRemoveEvent,
}),
[providedEventHandlers, handleAddEvent, handleRemoveEvent],
);
const eventHandlers = useMemo<LeafletEventHandlerFnMap>(
() => ({
...providedEventHandlers,
add: handleAddEvent,
remove: handleRemoveEvent,
}),
[providedEventHandlers, handleAddEvent, handleRemoveEvent],
);
let portalTarget: null | HTMLElement = null;
if (markerRendered) {
portalTarget = document.getElementById(id);
}
let portalTarget: null | HTMLElement = null;
if (markerRendered) {
portalTarget = document.getElementById(id);
}
useEffect(() => {
if (!portalTarget) return;
useEffect(() => {
if (!portalTarget) return;
if (disableClickPropagation) {
DomEvent.disableClickPropagation(portalTarget);
}
if (disableClickPropagation) {
DomEvent.disableClickPropagation(portalTarget);
}
if (disableScrollPropagation) {
DomEvent.disableScrollPropagation(portalTarget);
}
}, [portalTarget, disableClickPropagation, disableScrollPropagation]);
if (disableScrollPropagation) {
DomEvent.disableScrollPropagation(portalTarget);
}
}, [portalTarget, disableClickPropagation, disableScrollPropagation]);
return (
<>
<ReactLeafletMarker
ref={ref}
{...otherProps}
eventHandlers={eventHandlers}
icon={icon}
/>
return (
<>
<ReactLeafletMarker
ref={ref}
{...otherProps}
eventHandlers={eventHandlers}
icon={icon}
/>
{markerRendered && portalTarget !== null && (
<>
<InPortal node={portalNode}>{providedIcon}</InPortal>
{createPortal(<OutPortal node={portalNode} />, portalTarget)}
</>
)}
</>
);
},
{markerRendered && portalTarget !== null && (
<>
<InPortal node={portalNode}>{providedIcon}</InPortal>
{createPortal(<OutPortal node={portalNode} />, portalTarget)}
</>
)}
</>
);
},
);
ComponentMarker.displayName = "ComponentMarker";
@ -342,59 +344,59 @@ ComponentMarker.displayName = "ComponentMarker";
* ```
**/
export const Marker = forwardRef<LeafletMarker, MarkerProps>(
({ icon: Icon, componentIconOpts, ...otherProps }, ref) => {
const validElement = isValidElement(Icon);
const validComponent = isValidElementType(Icon);
({ icon: Icon, componentIconOpts, ...otherProps }, ref) => {
const validElement = isValidElement(Icon);
const validComponent = isValidElementType(Icon);
useEffect(() => {
if (
!validElement &&
!validComponent &&
componentIconOpts !== undefined &&
componentIconOpts.unusedOptsWarning !== false
) {
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.
useEffect(() => {
if (
!validElement &&
!validComponent &&
componentIconOpts !== undefined &&
componentIconOpts.unusedOptsWarning !== false
) {
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.
To disable this warning set 'componentIconOpts.unusedOptsWarning' to false.`,
);
}
}, [
componentIconOpts,
componentIconOpts?.unusedOptsWarning,
validElement,
validComponent,
]);
);
}
}, [
componentIconOpts,
componentIconOpts?.unusedOptsWarning,
validElement,
validComponent,
]);
if (validElement) {
return (
<ComponentMarker
ref={ref}
icon={Icon}
componentIconOpts={componentIconOpts}
{...otherProps}
/>
);
}
if (validElement) {
return (
<ComponentMarker
ref={ref}
icon={Icon}
componentIconOpts={componentIconOpts}
{...otherProps}
/>
);
}
if (validComponent) {
return (
<ComponentMarker
ref={ref}
icon={<Icon />}
componentIconOpts={componentIconOpts}
{...otherProps}
/>
);
}
if (validComponent) {
return (
<ComponentMarker
ref={ref}
icon={<Icon />}
componentIconOpts={componentIconOpts}
{...otherProps}
/>
);
}
return (
<ReactLeafletMarker
ref={ref}
icon={Icon as ReactLeafletMarkerProps["icon"]}
{...otherProps}
/>
);
},
return (
<ReactLeafletMarker
ref={ref}
icon={Icon as ReactLeafletMarkerProps["icon"]}
{...otherProps}
/>
);
},
);
Marker.displayName = "Marker";

View file

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

View file

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

View file

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

View file

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

View file

@ -1,50 +1,49 @@
"use client";
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 { 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() {
const [open, setOpen] = useState(false);
const router = useRouter();
useEffect(() => {
router.events.on("routeChangeStart", () => {
setOpen(false);
});
}, []);
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" className="p-2" aria-label="ricercaBtn">
<Search className="size-5" />
</Button>
</DialogTrigger>
<DialogContent className="border-none bg-transparent sm:max-w-xl [&_[data-slot=dialog-close]]:hidden">
<DialogHeader className="sr-only">
<DialogTitle>Ricerca</DialogTitle>
<DialogDescription>Effettua una ricerca</DialogDescription>
</DialogHeader>
<div className="relative mx-auto flex w-full gap-2">
<CodiceBox
key={"codiceBox"}
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"
/>
</div>
</DialogContent>
</Dialog>
</>
);
const [open, setOpen] = useState(false);
const router = useRouter();
useEffect(() => {
router.events.on("routeChangeStart", () => {
setOpen(false);
});
}, []);
return (
<>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" className="p-2" aria-label="ricercaBtn">
<Search className="size-5" />
</Button>
</DialogTrigger>
<DialogContent className="border-none bg-transparent sm:max-w-xl [&_[data-slot=dialog-close]]:hidden">
<DialogHeader className="sr-only">
<DialogTitle>Ricerca</DialogTitle>
<DialogDescription>Effettua una ricerca</DialogDescription>
</DialogHeader>
<div className="relative mx-auto flex w-full gap-2">
<CodiceBox
key={"codiceBox"}
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"
/>
</div>
</DialogContent>
</Dialog>
</>
);
}

View file

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

View file

@ -1,51 +1,51 @@
"use client";
import { usePathname } from "next/navigation";
import { MainNav } from "~/components/navbar/main-nav";
import { MobileNav } from "~/components/navbar/mobile-nav";
import { useState } from "react";
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 { SettingsPopover } from "~/components/navbar/settings-modal";
export function SiteHeader() {
const pathname = usePathname();
const [openSidebar, setOpenSidebar] = useState(false);
const [openUserMenu, setOpenUserMenu] = useState(false);
const [openUserMenuSidebar, setOpenUserMenuSidebar] = useState(false);
const pathname = usePathname();
const [openSidebar, setOpenSidebar] = useState(false);
const [openUserMenu, setOpenUserMenu] = useState(false);
const [openUserMenuSidebar, setOpenUserMenuSidebar] = useState(false);
const handleOpenMenu = (status: boolean) => {
setOpenUserMenu(status);
};
const handleOpenMenu = (status: boolean) => {
setOpenUserMenu(status);
};
const handleOpenSidebar = (status: boolean) => {
setOpenSidebar(status);
};
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">
<div className="mx-auto flex h-14 w-full items-center pr-3 pl-4 md:pr-4 md:pl-8">
<MainNav pathname={pathname} />
<MobileNav
pathname={pathname}
open={openSidebar}
setOpen={setOpenSidebar}
userOpen={openUserMenuSidebar}
setUserOpen={setOpenUserMenuSidebar}
/>
const handleOpenSidebar = (status: boolean) => {
setOpenSidebar(status);
};
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">
<div className="mx-auto flex h-14 w-full items-center pr-3 pl-4 md:pr-4 md:pl-8">
<MainNav pathname={pathname} />
<MobileNav
pathname={pathname}
open={openSidebar}
setOpen={setOpenSidebar}
userOpen={openUserMenuSidebar}
setUserOpen={setOpenUserMenuSidebar}
/>
<div className="float-end flex flex-1 items-center justify-end gap-0.5">
<RicercaCommand />
<div className="float-end flex flex-1 items-center justify-end gap-0.5">
<RicercaCommand />
<div className="hidden md:block">
<SettingsPopover />
</div>
<UserHeaderSection
key={pathname}
open={openUserMenu}
setOpen={handleOpenMenu}
setSidebar={handleOpenSidebar}
/>
</div>
</div>
</header>
);
<div className="hidden md:block">
<SettingsPopover />
</div>
<UserHeaderSection
key={pathname}
open={openUserMenu}
setOpen={handleOpenMenu}
setSidebar={handleOpenSidebar}
/>
</div>
</div>
</header>
);
}

View file

@ -1,43 +1,43 @@
import { motion, type Transition } from "framer-motion";
import { Moon, Sun } from "lucide-react";
import { useTheme } from "next-themes";
import { motion, type Transition } from "framer-motion";
import { cn } from "~/lib/utils";
export const ThemeSwitch = () => {
const { theme, setTheme } = useTheme();
const { theme, setTheme } = useTheme();
const spring: Transition = {
type: "spring",
stiffness: 700,
damping: 30,
};
const spring: Transition = {
type: "spring",
stiffness: 700,
damping: 30,
};
return (
<button
aria-label="Toggle Theme"
type="button"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className={cn(
`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",
)}
>
<motion.div
className={cn(
"flex size-8 items-center justify-center rounded-full bg-black/90",
//theme == "light" && "bg-sky-500",
)}
layout
transition={spring}
>
<motion.div whileTap={{ rotate: 360 }}>
{theme == "light" ? (
<Sun className="size-6 stroke-yellow-300" />
) : (
<Moon className="size-6 stroke-slate-200" />
)}
</motion.div>
</motion.div>
</button>
);
return (
<button
aria-label="Toggle Theme"
type="button"
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
className={cn(
`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",
)}
>
<motion.div
className={cn(
"flex size-8 items-center justify-center rounded-full bg-black/90",
//theme == "light" && "bg-sky-500",
)}
layout
transition={spring}
>
<motion.div whileTap={{ rotate: 360 }}>
{theme === "light" ? (
<Sun className="size-6 stroke-yellow-300" />
) : (
<Moon className="size-6 stroke-slate-200" />
)}
</motion.div>
</motion.div>
</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";
interface PaymentStatusProps {
status: PaymentStatus;
title: string;
message?: string;
status: PaymentStatus;
title: string;
message?: string;
}
export default function PaymentStatus({
status,
message,
title,
status,
message,
title,
}: PaymentStatusProps) {
const statusConfig = {
success: {
icon: CheckCircle,
color: "text-green-500",
bgColor: "bg-green-50",
borderColor: "border-green-200",
},
processing: {
icon: Loader2,
color: "text-blue-500",
bgColor: "bg-blue-50",
borderColor: "border-blue-200",
},
error: {
icon: AlertCircle,
color: "text-red-500",
bgColor: "bg-red-50",
borderColor: "border-red-200",
},
};
const statusConfig = {
success: {
icon: CheckCircle,
color: "text-green-500",
bgColor: "bg-green-50",
borderColor: "border-green-200",
},
processing: {
icon: Loader2,
color: "text-blue-500",
bgColor: "bg-blue-50",
borderColor: "border-blue-200",
},
error: {
icon: AlertCircle,
color: "text-red-500",
bgColor: "bg-red-50",
borderColor: "border-red-200",
},
};
const { icon: Icon, color, bgColor, borderColor } = statusConfig[status];
const { icon: Icon, color, bgColor, borderColor } = statusConfig[status];
return (
<div
className={`mb-4 flex items-center rounded-lg border p-4 ${bgColor} ${borderColor}`}
>
<div
className={`size-8 shrink-0 ${color} ${status === "processing" ? "animate-spin" : ""}`}
>
<Icon className="size-8" />
</div>
<div className="ml-3">
<h3 className={`text-sm font-medium ${color}`}>{title}</h3>
{message && <div className="mt-2 text-sm text-gray-700">{message}</div>}
</div>
</div>
);
return (
<div
className={`mb-4 flex items-center rounded-lg border p-4 ${bgColor} ${borderColor}`}
>
<div
className={`size-8 shrink-0 ${color} ${status === "processing" ? "animate-spin" : ""}`}
>
<Icon className="size-8" />
</div>
<div className="ml-3">
<h3 className={`text-sm font-medium ${color}`}>{title}</h3>
{message && <div className="mt-2 text-sm text-gray-700">{message}</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 { Button } from "~/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
} from "~/components/ui/command";
import { Input } from "~/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "~/components/ui/popover";
import { ScrollArea } from "~/components/ui/scroll-area";
import { cn } from "~/lib/utils";
type PhoneInputProps = Omit<
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
React.ComponentProps<"input">,
"onChange" | "value" | "ref"
> &
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
Omit<RPNInput.Props<typeof RPNInput.default>, "onChange"> & {
onChange?: (value: RPNInput.Value) => void;
};
const PhoneInput: React.ForwardRefExoticComponent<PhoneInputProps> = React.memo(
React.forwardRef<
React.ComponentRef<typeof RPNInput.default>,
PhoneInputProps
>(({ className, onChange, ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn(
"[&_input]:ph-input-fix flex h-auto w-full flex-1 [&_*]:!h-auto",
className,
)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
smartCaret={false}
defaultCountry="IT"
international
labels={EN}
focusInputOnCountrySelection
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props}
/>
);
}),
React.forwardRef<
React.ComponentRef<typeof RPNInput.default>,
PhoneInputProps
>(({ className, onChange, ...props }, ref) => {
return (
<RPNInput.default
ref={ref}
className={cn(
"[&_input]:ph-input-fix flex h-auto w-full flex-1 [&_*]:!h-auto",
className,
)}
flagComponent={FlagComponent}
countrySelectComponent={CountrySelect}
inputComponent={InputComponent}
smartCaret={false}
defaultCountry="IT"
international
labels={EN}
focusInputOnCountrySelection
/**
* Handles the onChange event.
*
* react-phone-number-input might trigger the onChange event as undefined
* when a valid phone number is not entered. To prevent this,
* the value is coerced to an empty string.
*
* @param {E164Number | undefined} value - The entered value
*/
onChange={(value) => onChange?.(value || ("" as RPNInput.Value))}
{...props}
/>
);
}),
);
PhoneInput.displayName = "PhoneInput";
const InputComponent = React.forwardRef<
HTMLInputElement,
React.ComponentProps<"input">
HTMLInputElement,
React.ComponentProps<"input">
>(({ className, ...props }, ref) => (
<Input
className={cn("w-full flex-1 rounded-s-none rounded-e-md", className)}
{...props}
ref={ref}
/>
<Input
className={cn("w-full flex-1 rounded-s-none rounded-e-md", className)}
{...props}
ref={ref}
/>
));
InputComponent.displayName = "InputComponent";
type CountryEntry = { label: string; value: RPNInput.Country | undefined };
type CountrySelectProps = {
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
disabled?: boolean;
value: RPNInput.Country;
options: CountryEntry[];
onChange: (country: RPNInput.Country) => void;
};
const CountrySelect = React.memo(
({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
const [filteredCountries, setFilteredCountries] = React.useState<
CountryEntry[]
>(countryList || []);
const [open, setOpen] = React.useState(false);
const [parentNode, setParentNode] = React.useState<HTMLDivElement | null>(
null,
);
({
disabled,
value: selectedCountry,
options: countryList,
onChange,
}: CountrySelectProps) => {
const [filteredCountries, setFilteredCountries] = React.useState<
CountryEntry[]
>(countryList || []);
const [open, setOpen] = React.useState(false);
const [parentNode, setParentNode] = React.useState<HTMLDivElement | null>(
null,
);
const refCallback = React.useCallback((node: HTMLDivElement) => {
if (node) {
setParentNode(node);
}
}, []);
const refCallback = React.useCallback((node: HTMLDivElement) => {
if (node) {
setParentNode(node);
}
}, []);
const virtualizer = useVirtualizer({
count: filteredCountries.length,
getScrollElement: () => parentNode,
estimateSize: () => 35,
overscan: 5,
});
const virtualizer = useVirtualizer({
count: filteredCountries.length,
getScrollElement: () => parentNode,
estimateSize: () => 35,
overscan: 5,
});
const handleSearch = (search: string) => {
if (!search.trim()) {
setFilteredCountries(countryList);
} else {
setFilteredCountries(
countryList.filter((country) =>
country.label.toLowerCase().includes(search.toLowerCase()),
),
);
}
};
const handleSearch = (search: string) => {
if (!search.trim()) {
setFilteredCountries(countryList);
} else {
setFilteredCountries(
countryList.filter((country) =>
country.label.toLowerCase().includes(search.toLowerCase()),
),
);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
role="combobox"
variant="outline"
aria-expanded={open}
className="flex gap-1 rounded-md rounded-e-none border-r-0 px-3 py-2 focus:z-10"
>
<FlagComponent
country={selectedCountry}
countryName={
countryList.find((c) => c.value === selectedCountry)?.label ||
""
}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100",
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search country..."
onValueChange={handleSearch}
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/>
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
role="combobox"
variant="outline"
aria-expanded={open}
className="flex gap-1 rounded-md rounded-e-none border-r-0 px-3 py-2 focus:z-10"
>
<FlagComponent
country={selectedCountry}
countryName={
countryList.find((c) => c.value === selectedCountry)?.label ||
""
}
/>
<ChevronsUpDown
className={cn(
"-mr-2 size-4 opacity-50",
disabled ? "hidden" : "opacity-100",
)}
/>
</Button>
</PopoverTrigger>
<PopoverContent className="w-[300px] p-0">
<Command shouldFilter={false}>
<CommandInput
placeholder="Search country..."
onValueChange={handleSearch}
className="border-none ring-0 focus:ring-0 focus-visible:ring-0"
/>
<ScrollArea className="h-[400px] overflow-auto">
<CommandEmpty>No countries found</CommandEmpty>
<CommandGroup ref={refCallback}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer
.getVirtualItems()
.map(({ index, size, start }) => {
const country = filteredCountries[index];
if (!country?.value) return null;
return (
<CommandItem
key={country.value}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${size}px`,
transform: `translateY(${start}px)`,
}}
onSelect={() => onChange(country.value!)}
value={country.value}
>
<FlagComponent
country={country.value}
countryName={country.label}
/>
<span className="flex-1 text-sm">
{country.label}
</span>
<span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(
country.value,
)}`}</span>
<CheckIcon
className={`ml-auto size-4 ${
country.value === selectedCountry
? "opacity-100"
: "opacity-0"
}`}
/>
</CommandItem>
);
})}
</div>
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
},
<ScrollArea className="h-[400px] overflow-auto">
<CommandEmpty>No countries found</CommandEmpty>
<CommandGroup ref={refCallback}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{virtualizer
.getVirtualItems()
.map(({ index, size, start }) => {
const country = filteredCountries[index];
if (!country?.value) return null;
return (
<CommandItem
key={country.value}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${size}px`,
transform: `translateY(${start}px)`,
}}
// biome-ignore lint/style/noNonNullAssertion: <exists>
onSelect={() => onChange(country.value!)}
value={country.value}
>
<FlagComponent
country={country.value}
countryName={country.label}
/>
<span className="flex-1 text-sm">
{country.label}
</span>
<span className="text-foreground/50 text-sm">{`+${RPNInput.getCountryCallingCode(
country.value,
)}`}</span>
<CheckIcon
className={`ml-auto size-4 ${
country.value === selectedCountry
? "opacity-100"
: "opacity-0"
}`}
/>
</CommandItem>
);
})}
</div>
</CommandGroup>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
);
},
);
CountrySelect.displayName = "CountrySelect";
const FlagComponent = ({ country, countryName }: RPNInput.FlagProps) => {
const Flag = flags[country];
const Flag = flags[country];
return (
<span className="flex size-6 items-center justify-center overflow-hidden rounded-sm">
{Flag ? <Flag title={countryName} /> : <Globe className="size-4" />}
</span>
);
return (
<span className="flex size-6 items-center justify-center overflow-hidden rounded-sm">
{Flag ? <Flag title={countryName} /> : <Globe className="size-4" />}
</span>
);
};
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 {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "~/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs";
import { cn } from "~/lib/utils";
import { AnimatedNumber } from "~/components/custom_ui/animated-number";
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 { useTranslation } from "~/providers/I18nProvider";
import TipologiaPosizioneEnum from "~/schemas/public/TipologiaPosizioneEnum";
import { useEffect, useState } from "react";
import { api } from "~/utils/api";
export const PricingChoice = () => {
const { t } = useTranslation();
const { data: prezziario, isLoading } =
api.prezziario.getPrezziPerTipologiaAll.useQuery();
if (isLoading) return <LoadingPage />;
if (!prezziario) return null;
const { t } = useTranslation();
const { data: prezziario, isLoading } =
api.prezziario.getPrezziPerTipologiaAll.useQuery();
if (isLoading) return <LoadingPage />;
if (!prezziario) return null;
const pricingBreve = prezziario.transitorio.saldi.map((s) => ({
downPayment: prezziario.transitorio.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
const pricingBreve = prezziario.transitorio.saldi.map((s) => ({
downPayment: prezziario.transitorio.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
const pricingStabile = prezziario.stabile.saldi.map((s) => ({
downPayment: prezziario.stabile.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
const pricingStabile = prezziario.stabile.saldi.map((s) => ({
downPayment: prezziario.stabile.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
return (
<div className="text-primary flex flex-col items-center justify-center gap-4">
<h2 className="text-3xl font-semibold">Scegli il tuo servizio</h2>
<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">
<TabsTrigger
value="transitorio"
className="bg-transitorio [&[data-state=active]]:bg-transitorio w-full cursor-pointer px-2 text-xl sm:px-3"
>
Transitorio
</TabsTrigger>
<TabsTrigger
value="stabile"
className="bg-stabile [&[data-state=active]]:bg-stabile w-full cursor-pointer px-2 text-xl sm:px-3"
>
Stabile
</TabsTrigger>
</TabsList>
<TabsContent value="transitorio" className="w-full">
<PricingComponent
title={t.prezzi.titolo_transitori}
cta={t.pricing_cmp.cta_breve}
pricingData={pricingBreve}
opzioni={[
t.pricing_cmp.breve_option1,
t.pricing_cmp.breve_option2,
t.pricing_cmp.breve_option3,
t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5,
]}
className="border-transitorio bg-transitorio"
tipo={TipologiaPosizioneEnum.Transitorio}
/>
</TabsContent>
<TabsContent value="stabile">
<PricingComponent
title={t.prezzi.titolo_stabile}
cta={t.pricing_cmp.cta_stabile}
pricingData={pricingStabile}
opzioni={[
t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2,
]}
className="border-stabile bg-stabile"
tipo={TipologiaPosizioneEnum.Stabile}
/>
</TabsContent>
</Tabs>
return (
<div className="text-primary flex flex-col items-center justify-center gap-4">
<h2 className="text-3xl font-semibold">Scegli il tuo servizio</h2>
<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">
<TabsTrigger
value="transitorio"
className="bg-transitorio [&[data-state=active]]:bg-transitorio w-full cursor-pointer px-2 text-xl sm:px-3"
>
Transitorio
</TabsTrigger>
<TabsTrigger
value="stabile"
className="bg-stabile [&[data-state=active]]:bg-stabile w-full cursor-pointer px-2 text-xl sm:px-3"
>
Stabile
</TabsTrigger>
</TabsList>
<TabsContent value="transitorio" className="w-full">
<PricingComponent
title={t.prezzi.titolo_transitori}
cta={t.pricing_cmp.cta_breve}
pricingData={pricingBreve}
opzioni={[
t.pricing_cmp.breve_option1,
t.pricing_cmp.breve_option2,
t.pricing_cmp.breve_option3,
t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5,
]}
className="border-transitorio bg-transitorio"
tipo={TipologiaPosizioneEnum.Transitorio}
/>
</TabsContent>
<TabsContent value="stabile">
<PricingComponent
title={t.prezzi.titolo_stabile}
cta={t.pricing_cmp.cta_stabile}
pricingData={pricingStabile}
opzioni={[
t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2,
]}
className="border-stabile bg-stabile"
tipo={TipologiaPosizioneEnum.Stabile}
/>
</TabsContent>
</Tabs>
<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">
<span>Scopri di più sui prezzi</span>
<ExternalLink className="size-5" />
</Button>
</Link>
</div>
);
<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">
<span>Scopri di più sui prezzi</span>
<ExternalLink className="size-5" />
</Button>
</Link>
</div>
);
};
type PricingData = { downPayment: number; secondPayment: number }[];
export const PricingComponent = ({
pricingData,
opzioni,
cta,
statico = false,
className,
title,
tipo,
pricingData,
opzioni,
cta,
statico = false,
className,
title,
tipo,
}: {
pricingData: PricingData;
opzioni: string[];
title: string;
cta: string;
statico?: boolean;
className?: string;
tipo: TipologiaPosizioneEnum;
pricingData: PricingData;
opzioni: string[];
title: string;
cta: string;
statico?: boolean;
className?: string;
tipo: TipologiaPosizioneEnum;
}) => {
const { t } = useTranslation();
const [Value, setValue] = useState<number>(0);
const p = pricingData[Value];
const downPayment = p?.downPayment || 0;
const secondPayment = p?.secondPayment || 0;
const { t } = useTranslation();
const [Value, setValue] = useState<number>(0);
const p = pricingData[Value];
const downPayment = p?.downPayment || 0;
const secondPayment = p?.secondPayment || 0;
const customFormat = (num: number) => `${num}`;
const customFormat = (num: number) => `${num}`;
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (statico) return;
const interval = setInterval(() => {
setValue((prevValue) => (prevValue + 1) % pricingData.length);
}, 2500);
setIntervalId(interval);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (statico) return;
const interval = setInterval(() => {
setValue((prevValue) => (prevValue + 1) % pricingData.length);
}, 2500);
setIntervalId(interval);
return () => clearInterval(interval);
}, []);
return () => clearInterval(interval);
}, []);
const ClearCarousel = () => {
if (intervalId) {
clearInterval(intervalId);
}
};
const ClearCarousel = () => {
if (intervalId) {
clearInterval(intervalId);
}
};
return (
<Card className={cn("w-full max-w-xl gap-4 py-4", className)}>
<CardHeader className="py-2">
<CardTitle className="pt-1 text-center text-3xl font-semibold">
{title}
</CardTitle>
<CardDescription className="sr-only">
Prezzo del servizio
</CardDescription>
</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">
<div
className="xs:gap-x-4 xs:gap-y-1 flex w-full items-center justify-center gap-0"
data-role="wrapper"
>
<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">
{t.acconto}
</h2>
<p
className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount"
>
{downPayment}
</p>
</div>
return (
<Card className={cn("w-full max-w-xl gap-4 py-4", className)}>
<CardHeader className="py-2">
<CardTitle className="pt-1 text-center text-3xl font-semibold">
{title}
</CardTitle>
<CardDescription className="sr-only">
Prezzo del servizio
</CardDescription>
</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">
<div
className="xs:gap-x-4 xs:gap-y-1 flex w-full items-center justify-center gap-0"
data-role="wrapper"
>
<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">
{t.acconto}
</h2>
<p
className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount"
>
{downPayment}
</p>
</div>
<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">
{t.saldo}
</h2>
<p
className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount"
>
<AnimatedNumber
value={secondPayment}
format={customFormat}
precision={secondPayment % 1 === 0 ? 0 : 2}
/>
</p>
</div>
<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">
{t.saldo}
</h2>
<p
className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount"
>
<AnimatedNumber
value={secondPayment}
format={customFormat}
precision={secondPayment % 1 === 0 ? 0 : 2}
/>
</p>
</div>
<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">
Contratto
</h2>
<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">
Contratto
</h2>
{tipo == TipologiaPosizioneEnum.Transitorio ? (
<div className="flex flex-col justify-center leading-none">
<span
className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount"
>
0
</span>
</div>
) : (
<Link
href={"/prezzi#contratti"}
className="flex flex-col justify-center leading-none sm:h-16"
>
<Button className="flex items-center gap-2">
Più info <ExternalLink className="size-4" />
</Button>
</Link>
)}
</div>
</div>
<div className="flex w-full flex-wrap items-center justify-center gap-2">
{opzioni.map((opzione, idx) => (
<Button
key={idx}
size="default"
className={cn(
"xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3",
idx === Value ? "animate-in fade-in-0" : "",
)}
variant={idx === Value ? "default" : "outline"}
onClick={() => {
ClearCarousel();
setValue(idx);
}}
>
{opzione}
</Button>
))}
</div>
<h2 className="text-center text-xl font-semibold text-white">{cta}</h2>
</CardContent>
</Card>
);
{tipo === TipologiaPosizioneEnum.Transitorio ? (
<div className="flex flex-col justify-center leading-none">
<span
className="xs:text-3xl text-2xl font-semibold sm:text-6xl"
data-role="amount"
>
0
</span>
</div>
) : (
<Link
href={"/prezzi#contratti"}
className="flex flex-col justify-center leading-none sm:h-16"
>
<Button className="flex items-center gap-2">
Più info <ExternalLink className="size-4" />
</Button>
</Link>
)}
</div>
</div>
<div className="flex w-full flex-wrap items-center justify-center gap-2">
{opzioni.map((opzione, idx) => (
<Button
key={idx}
size="default"
className={cn(
"xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3",
idx === Value ? "animate-in fade-in-0" : "",
)}
variant={idx === Value ? "default" : "outline"}
onClick={() => {
ClearCarousel();
setValue(idx);
}}
>
{opzione}
</Button>
))}
</div>
<h2 className="text-center text-xl font-semibold text-white">{cta}</h2>
</CardContent>
</Card>
);
};
export const PricingComponentConsulenza = ({
pricingData,
opzioni,
cta,
statico = false,
className,
title,
pricingData,
opzioni,
cta,
statico = false,
className,
title,
}: {
pricingData: number[];
opzioni: string[];
title: string;
cta: string;
statico?: boolean;
className?: string;
pricingData: number[];
opzioni: string[];
title: string;
cta: string;
statico?: boolean;
className?: string;
}) => {
const [Value, setValue] = useState<number>(0);
const p = pricingData[Value];
const [Value, setValue] = useState<number>(0);
const p = pricingData[Value];
const customFormat = (num: number) => `${num}`;
const customFormat = (num: number) => `${num}`;
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (statico) return;
const interval = setInterval(() => {
setValue((prevValue) => (prevValue + 1) % pricingData.length);
}, 2500);
setIntervalId(interval);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
if (statico) return;
const interval = setInterval(() => {
setValue((prevValue) => (prevValue + 1) % pricingData.length);
}, 2500);
setIntervalId(interval);
return () => clearInterval(interval);
}, []);
return () => clearInterval(interval);
}, []);
const ClearCarousel = () => {
if (intervalId) {
clearInterval(intervalId);
}
};
const ClearCarousel = () => {
if (intervalId) {
clearInterval(intervalId);
}
};
return (
<Card className={cn("w-full max-w-xl", className)}>
<CardHeader className="p-3 px-5">
<CardTitle className="pt-1 text-center text-3xl font-semibold">
{title}
</CardTitle>
<CardDescription className="sr-only">
Prezzo del servizio
</CardDescription>
</CardHeader>
<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="flex flex-col items-center gap-2">
<p className="xs:text-6xl text-3xl font-semibold">
<AnimatedNumber value={p || 0} format={customFormat} />
</p>
</div>
</div>
<div className="flex w-full flex-wrap items-center justify-center gap-2">
{opzioni.map((opzione, idx) => (
<Button
key={idx}
size="default"
className={cn(
"xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3",
idx === Value ? "animate-in fade-in-0" : "",
)}
variant={idx === Value ? "default" : "outline"}
onClick={() => {
ClearCarousel();
setValue(idx);
}}
>
{opzione}
</Button>
))}
</div>
<h2 className="text-center text-xl font-semibold text-white">{cta}</h2>
</CardContent>
</Card>
);
return (
<Card className={cn("w-full max-w-xl", className)}>
<CardHeader className="p-3 px-5">
<CardTitle className="pt-1 text-center text-3xl font-semibold">
{title}
</CardTitle>
<CardDescription className="sr-only">
Prezzo del servizio
</CardDescription>
</CardHeader>
<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="flex flex-col items-center gap-2">
<p className="xs:text-6xl text-3xl font-semibold">
<AnimatedNumber value={p || 0} format={customFormat} />
</p>
</div>
</div>
<div className="flex w-full flex-wrap items-center justify-center gap-2">
{opzioni.map((opzione, idx) => (
<Button
key={idx}
size="default"
className={cn(
"xs:h-10 xs:px-4 xs:py-2 h-9 rounded-md border-none px-3",
idx === Value ? "animate-in fade-in-0" : "",
)}
variant={idx === Value ? "default" : "outline"}
onClick={() => {
ClearCarousel();
setValue(idx);
}}
>
{opzione}
</Button>
))}
</div>
<h2 className="text-center text-xl font-semibold text-white">{cta}</h2>
</CardContent>
</Card>
);
};
export const PrezziShow = () => {
const { t } = useTranslation();
const { data: prezziario, isLoading } =
api.prezziario.getPrezziPerTipologiaAll.useQuery();
if (isLoading) return <LoadingPage />;
if (!prezziario) return null;
const { t } = useTranslation();
const { data: prezziario, isLoading } =
api.prezziario.getPrezziPerTipologiaAll.useQuery();
if (isLoading) return <LoadingPage />;
if (!prezziario) return null;
const pricingBreve = prezziario.transitorio.saldi.map((s) => ({
downPayment: prezziario.transitorio.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
const pricingBreve = prezziario.transitorio.saldi.map((s) => ({
downPayment: prezziario.transitorio.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
const pricingStabile = prezziario.stabile.saldi.map((s) => ({
downPayment: prezziario.stabile.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
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="flex w-full flex-col gap-2 text-start sm:w-2/5 sm:text-left">
<h1 className="text-primary text-3xl font-bold">
Quanto costa il servizio?
</h1>
<p className="text-muted-foreground text-lg">
Il nostro servizio è pensato per essere accessibile e conveniente, con
tariffe chiare e senza sorprese. Basate sul tipo di affitto che
desideri, le nostre tariffe sono suddivise in due categorie
principali: Affitto Transitorio e Affitto Stabile.
</p>
</div>
<Tabs defaultValue="transitorio" className="w-full sm:w-3/5">
<TabsList className="flex h-auto w-full items-center justify-between">
<TabsTrigger value="transitorio" className="w-full text-xl">
Affitto Transitorio
</TabsTrigger>
<TabsTrigger value="stabile" className="w-full text-xl">
Affitto Stabile
</TabsTrigger>
</TabsList>
<TabsContent value="transitorio">
<PricingComponent
title={t.prezzi.titolo_transitori}
cta={t.pricing_cmp.cta_breve}
pricingData={pricingBreve}
opzioni={[
t.pricing_cmp.breve_option1,
t.pricing_cmp.breve_option2,
t.pricing_cmp.breve_option3,
t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5,
]}
tipo={TipologiaPosizioneEnum.Transitorio}
className="border-transitorio bg-transitorio"
/>
</TabsContent>
<TabsContent value="stabile">
<PricingComponent
title={t.prezzi.titolo_stabile}
cta={t.pricing_cmp.cta_stabile}
pricingData={pricingStabile}
opzioni={[
t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2,
]}
className="border-stabile bg-stabile"
tipo={TipologiaPosizioneEnum.Stabile}
/>
</TabsContent>
</Tabs>
</div>
);
const pricingStabile = prezziario.stabile.saldi.map((s) => ({
downPayment: prezziario.stabile.acconto.prezzo_cent / 100,
secondPayment: s.prezzo_cent / 100,
}));
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="flex w-full flex-col gap-2 text-start sm:w-2/5 sm:text-left">
<h1 className="text-primary text-3xl font-bold">
Quanto costa il servizio?
</h1>
<p className="text-muted-foreground text-lg">
Il nostro servizio è pensato per essere accessibile e conveniente, con
tariffe chiare e senza sorprese. Basate sul tipo di affitto che
desideri, le nostre tariffe sono suddivise in due categorie
principali: Affitto Transitorio e Affitto Stabile.
</p>
</div>
<Tabs defaultValue="transitorio" className="w-full sm:w-3/5">
<TabsList className="flex h-auto w-full items-center justify-between">
<TabsTrigger value="transitorio" className="w-full text-xl">
Affitto Transitorio
</TabsTrigger>
<TabsTrigger value="stabile" className="w-full text-xl">
Affitto Stabile
</TabsTrigger>
</TabsList>
<TabsContent value="transitorio">
<PricingComponent
title={t.prezzi.titolo_transitori}
cta={t.pricing_cmp.cta_breve}
pricingData={pricingBreve}
opzioni={[
t.pricing_cmp.breve_option1,
t.pricing_cmp.breve_option2,
t.pricing_cmp.breve_option3,
t.pricing_cmp.breve_option4,
t.pricing_cmp.breve_option5,
]}
tipo={TipologiaPosizioneEnum.Transitorio}
className="border-transitorio bg-transitorio"
/>
</TabsContent>
<TabsContent value="stabile">
<PricingComponent
title={t.prezzi.titolo_stabile}
cta={t.pricing_cmp.cta_stabile}
pricingData={pricingStabile}
opzioni={[
t.pricing_cmp.stabile_option1,
t.pricing_cmp.stabile_option2,
]}
className="border-stabile bg-stabile"
tipo={TipologiaPosizioneEnum.Stabile}
/>
</TabsContent>
</Tabs>
</div>
);
};

View file

@ -6,57 +6,57 @@ import { useEffect, useState } from "react";
import { Progress } from "~/components/ui/progress";
export const ProgressRedirect = ({ href }: { href: string }) => {
const [progress, setProgress] = useState(0);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const router = useRouter();
const handleProgress = () => {
setProgress((prev) => {
if (prev >= 100) {
if (intervalId) {
clearInterval(intervalId);
}
const [progress, setProgress] = useState(0);
const [intervalId, setIntervalId] = useState<NodeJS.Timeout | null>(null);
const router = useRouter();
const handleProgress = () => {
setProgress((prev) => {
if (prev >= 100) {
if (intervalId) {
clearInterval(intervalId);
}
return 100;
}
return prev + 20; // Increment progress by 20% every second
});
};
useEffect(() => {
const id = setInterval(handleProgress, 1000); // Update progress every second
setIntervalId(id);
return () => {
if (id) {
clearInterval(id);
}
};
}, []);
return 100;
}
return prev + 20; // Increment progress by 20% every second
});
};
useEffect(() => {
const id = setInterval(handleProgress, 1000); // Update progress every second
setIntervalId(id);
return () => {
if (id) {
clearInterval(id);
}
};
}, []);
useEffect(() => {
async function redirect() {
await router.push(href);
}
useEffect(() => {
async function redirect() {
await router.push(href);
}
if (progress == 100) {
redirect().catch((error) => {
console.error("Error during redirect:", error);
});
}
}, [progress]);
return (
<div className="flex flex-col items-center justify-center gap-4">
<Progress value={progress} className="h-3" />
if (progress === 100) {
redirect().catch((error) => {
console.error("Error during redirect:", error);
});
}
}, [progress]);
return (
<div className="flex flex-col items-center justify-center gap-4">
<Progress value={progress} className="h-3" />
<div className="flex items-center justify-center gap-2">
<p className="text-muted-foreground">
Stai venendo reindirizzato... oppure
</p>
<Link
href={href}
className="font-semibold underline underline-offset-1"
>
Clicca qui
</Link>
</div>
</div>
);
<div className="flex items-center justify-center gap-2">
<p className="text-muted-foreground">
Stai venendo reindirizzato... oppure
</p>
<Link
href={href}
className="font-semibold underline underline-offset-1"
>
Clicca qui
</Link>
</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 Link from "next/link";
import { handleConsegna } from "~/lib/annuncio_details";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle,
CredenzaTrigger,
} from "~/components/custom_ui/credenza";
import {
Carousel,
CarouselContent,
CarouselItem,
CarouselNext,
CarouselPrevious,
} from "~/components/ui/carousel";
import { ImageFlbk } from "~/components/ImageWithFallback";
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 }) => {
const { data } = useServizioAnnuncio();
const { data } = useServizioAnnuncio();
return (
<BasicAnnuncioCard
data={data}
className={className}
actions={<AnnuncioActions />}
interactions={<Interactions />}
/>
);
return (
<BasicAnnuncioCard
data={data}
className={className}
actions={<AnnuncioActions />}
interactions={<Interactions />}
/>
);
};
export const BasicAnnuncioCard = ({
actions,
interactions,
className,
data,
actions,
interactions,
className,
data,
}: {
actions?: React.ReactNode;
interactions?: React.ReactNode;
className?: string;
data: Pick<
Annunci,
| "id"
| "codice"
| "prezzo"
| "desc_en"
| "desc_it"
| "url_immagini"
| "titolo_en"
| "titolo_it"
| "tipo"
| "consegna"
| "stato"
| "web"
>;
actions?: React.ReactNode;
interactions?: React.ReactNode;
className?: string;
data: Pick<
Annunci,
| "id"
| "codice"
| "prezzo"
| "desc_en"
| "desc_it"
| "url_immagini"
| "titolo_en"
| "titolo_it"
| "tipo"
| "consegna"
| "stato"
| "web"
>;
}) => {
const { t } = useTranslation();
const { t } = useTranslation();
return (
<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">
<CardTitle className="text-2xl">Codice: {data.codice}</CardTitle>
{actions}
</CardHeader>
<CardContent className="px-4">
<div className="flex w-full flex-col gap-4 md:flex-row md:gap-10">
<ImageFlbk
src={
(data.url_immagini &&
`/go-api/images/get/${data.url_immagini[0]}`) ||
"/fallback-image.png"
}
priority
width={1920}
height={1080}
className="h-auto w-full rounded-md object-contain sm:max-w-xs"
alt={data.codice}
/>
<div className="flex w-full flex-col gap-5">
{interactions}
return (
<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">
<CardTitle className="text-2xl">Codice: {data.codice}</CardTitle>
{actions}
</CardHeader>
<CardContent className="px-4">
<div className="flex w-full flex-col gap-4 md:flex-row md:gap-10">
<ImageFlbk
src={
(data.url_immagini &&
`/go-api/images/get/${data.url_immagini[0]}`) ||
"/fallback-image.png"
}
priority
width={1920}
height={1080}
className="h-auto w-full rounded-md object-contain sm:max-w-xs"
alt={data.codice}
/>
<div className="flex w-full flex-col gap-5">
{interactions}
<div className="flex flex-wrap items-center gap-3">
<TipoBadge tipo={data.tipo} />
<span>
{handleConsegna({
consegna: data.consegna,
consegna_da: t.card.consegna_da,
mesi: t.preferenze.mesi,
subito: t.card.consegna_subito,
aggiornamento: t.card.in_aggiornamento,
})}
</span>
<span>{formatCurrency(data.prezzo / 1e2)}</span>
</div>
<div className="flex w-full flex-wrap items-center gap-5">
<span className="text-wrap">{data.titolo_it || data.codice}</span>
<div className="flex flex-wrap items-center gap-2">
<AnnuncioDettaglio data={data} />
{data.stato !== "Sospeso" && data.web && (
<Link
href={`/annuncio/${data.codice}`}
aria-label="Pagina Annuncio"
>
<Button
variant="outline"
className="flex items-center gap-2"
>
<ExternalLink className="size-6" />
</Button>
</Link>
)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
<div className="flex flex-wrap items-center gap-3">
<TipoBadge tipo={data.tipo} />
<span>
{handleConsegna({
consegna: data.consegna,
consegna_da: t.card.consegna_da,
mesi: t.preferenze.mesi,
subito: t.card.consegna_subito,
aggiornamento: t.card.in_aggiornamento,
})}
</span>
<span>{formatCurrency(data.prezzo / 1e2)}</span>
</div>
<div className="flex w-full flex-wrap items-center gap-5">
<span className="text-wrap">{data.titolo_it || data.codice}</span>
<div className="flex flex-wrap items-center gap-2">
<AnnuncioDettaglio data={data} />
{data.stato !== "Sospeso" && data.web && (
<Link
href={`/annuncio/${data.codice}`}
aria-label="Pagina Annuncio"
>
<Button
variant="outline"
className="flex items-center gap-2"
>
<ExternalLink className="size-6" />
</Button>
</Link>
)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
};
const TipoBadge = ({ tipo }: { tipo: Annunci["tipo"] }) => {
return (
<div
className={cn(
"flex w-fit items-center justify-center rounded-md px-3",
tipo === "Transitorio" && "bg-orange-400",
tipo === "Stabile" && "bg-red-400",
tipo === "Cessione" && "bg-blue-400",
tipo === "Vendita" && "bg-green-400",
)}
>
<div className="font-semibold text-white">{tipo}</div>
</div>
);
return (
<div
className={cn(
"flex w-fit items-center justify-center rounded-md px-3",
tipo === "Transitorio" && "bg-orange-400",
tipo === "Stabile" && "bg-red-400",
tipo === "Cessione" && "bg-blue-400",
tipo === "Vendita" && "bg-green-400",
)}
>
<div className="font-semibold text-white">{tipo}</div>
</div>
);
};
const AnnuncioDettaglio = ({
data,
data,
}: {
data: Pick<
Annunci,
| "id"
| "codice"
| "prezzo"
| "desc_en"
| "desc_it"
| "url_immagini"
| "titolo_en"
| "titolo_it"
| "tipo"
| "consegna"
| "stato"
| "web"
>;
data: Pick<
Annunci,
| "id"
| "codice"
| "prezzo"
| "desc_en"
| "desc_it"
| "url_immagini"
| "titolo_en"
| "titolo_it"
| "tipo"
| "consegna"
| "stato"
| "web"
>;
}) => {
const { locale, t } = useTranslation();
function replaceWithBr(text: string) {
return text.replace(/\n/g, "<br />");
}
return (
<Credenza>
<CredenzaTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2"
aria-label="Dettagli Annuncio"
>
<UnfoldVertical className="size-6" /> Dettagli annuncio
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] md:max-w-6xl">
<CredenzaHeader>
<CredenzaTitle className="text-xl">
Annuncio {data.codice}
</CredenzaTitle>
<CredenzaDescription className="sr-only">
Annuncio {data.codice}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="scrollbar-default max-h-[80vh] overflow-y-auto pb-5">
<div className="flex flex-col gap-2 px-2">
<Carousel opts={{ loop: true }}>
<CarouselContent>
{data.url_immagini &&
data.url_immagini.map((img, idx) => (
<CarouselItem key={img}>
<ImageFlbk
alt={`${data.codice} - ${idx}`}
width={400}
height={200}
priority={idx === 0}
src={`/go-api/images/get/${img}`}
className={"h-80 w-full object-contain sm:h-[35rem]"}
/>
</CarouselItem>
))}
</CarouselContent>
<div
onKeyDown={(e) => e.stopPropagation()}
role="button"
tabIndex={0}
onClick={(e) => e.preventDefault()}
className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0"
>
<CarouselPrevious
className="left-2 cursor-pointer"
disabled={
!data.url_immagini || data.url_immagini.length === 1
}
/>
<CarouselNext
className="right-2 cursor-pointer"
disabled={
!data.url_immagini || data.url_immagini.length === 1
}
/>
</div>
</Carousel>
<div>
<TipoBadge tipo={data.tipo} />
const { locale, t } = useTranslation();
function replaceWithBr(text: string) {
return text.replace(/\n/g, "<br />");
}
return (
<Credenza>
<CredenzaTrigger asChild>
<Button
variant="outline"
className="flex items-center gap-2"
aria-label="Dettagli Annuncio"
>
<UnfoldVertical className="size-6" /> Dettagli annuncio
</Button>
</CredenzaTrigger>
<CredenzaContent className="max-h-[90vh] md:max-w-6xl">
<CredenzaHeader>
<CredenzaTitle className="text-xl">
Annuncio {data.codice}
</CredenzaTitle>
<CredenzaDescription className="sr-only">
Annuncio {data.codice}
</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody className="scrollbar-default max-h-[80vh] overflow-y-auto pb-5">
<div className="flex flex-col gap-2 px-2">
<Carousel opts={{ loop: true }}>
<CarouselContent>
{data?.url_immagini?.map((img, idx) => (
<CarouselItem key={img}>
<ImageFlbk
alt={`${data.codice} - ${idx}`}
width={400}
height={200}
priority={idx === 0}
src={`/go-api/images/get/${img}`}
className={"h-80 w-full object-contain sm:h-[35rem]"}
/>
</CarouselItem>
))}
</CarouselContent>
<div
onKeyDown={(e) => e.stopPropagation()}
role="button"
tabIndex={0}
onClick={(e) => e.preventDefault()}
className="block opacity-0 transition-all duration-200 group-hover:opacity-60 disabled:group-hover:opacity-0"
>
<CarouselPrevious
className="left-2 cursor-pointer"
disabled={
!data.url_immagini || data.url_immagini.length === 1
}
/>
<CarouselNext
className="right-2 cursor-pointer"
disabled={
!data.url_immagini || data.url_immagini.length === 1
}
/>
</div>
</Carousel>
<div>
<TipoBadge tipo={data.tipo} />
<div className="mt-1 space-y-2">
<div className="flex flex-row justify-around">
<div className="text-center">
<dt className="sr-only">{t.card.codice}</dt>
<div className="mt-1 space-y-2">
<div className="flex flex-row justify-around">
<div className="text-center">
<dt className="sr-only">{t.card.codice}</dt>
<dd className="text-lg font-semibold">
Cod: {data.codice}
</dd>
</div>
<div className="text-center">
<dt className="sr-only">{t.card.prezzo}</dt>
<dd className="text-lg font-semibold">
Cod: {data.codice}
</dd>
</div>
<div className="text-center">
<dt className="sr-only">{t.card.prezzo}</dt>
<dd className="text-lg font-semibold">
{formatCurrency(data.prezzo / 1e2)}
</dd>
</div>
</div>
<dd className="text-lg font-semibold">
{formatCurrency(data.prezzo / 1e2)}
</dd>
</div>
</div>
<div className="mb-3">
<dt className="sr-only">{t.card.titolo}</dt>
<div className="mb-3">
<dt className="sr-only">{t.card.titolo}</dt>
<dd className="text-base font-semibold md:text-lg">
{locale === "it" ? data.titolo_it : data.titolo_en}
</dd>
</div>
<dd className="text-base font-semibold md:text-lg">
{locale === "it" ? data.titolo_it : data.titolo_en}
</dd>
</div>
<div className="mb-3">
<dd
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceWithBr(
(locale === "it" ? data.desc_it : data.desc_it) || "",
),
}}
/>
</div>
</div>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button aria-label="Chiudi Credenza">Chiudi</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
<div className="mb-3">
<dd
className="text-base"
dangerouslySetInnerHTML={{
__html: replaceWithBr(
(locale === "it" ? data.desc_it : data.desc_it) || "",
),
}}
/>
</div>
</div>
</div>
</div>
</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button aria-label="Chiudi Credenza">Chiudi</Button>
</CredenzaClose>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
};

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