eslint migration biome
This commit is contained in:
parent
9185d8ddec
commit
3f82763168
348 changed files with 64918 additions and 67612 deletions
|
|
@ -17,6 +17,4 @@ knip.json
|
|||
check-env-vars.js
|
||||
headers
|
||||
.kanelrc.js
|
||||
eslint.config.mjs
|
||||
prettier.config.cjs
|
||||
kyselyRules
|
||||
33
apps/infoalloggi/.vscode/settings.json
vendored
33
apps/infoalloggi/.vscode/settings.json
vendored
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'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'annuncio
|
||||
</Button>
|
||||
</Section>
|
||||
</>
|
||||
</Base>
|
||||
);
|
||||
};
|
||||
export default ContattoAnnuncioEmail;
|
||||
|
|
|
|||
|
|
@ -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'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'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;
|
||||
|
|
|
|||
|
|
@ -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'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'annuncio →
|
||||
</Link>
|
||||
</Row>
|
||||
</Section>
|
||||
</>
|
||||
))}
|
||||
</Section>
|
||||
</Section>
|
||||
</>
|
||||
</Base>
|
||||
);
|
||||
};
|
||||
|
||||
export default OnboardingServizio;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: "~" },
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
40195
apps/infoalloggi/package-lock.json
generated
40195
apps/infoalloggi/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
/* eslint-env node */
|
||||
|
||||
|
||||
const config = {
|
||||
plugins: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: “{prezziario.idprezziario}”
|
||||
</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: “{prezziario.idprezziario}”
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 “{email.title}” 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 “{email.title}” 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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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='© <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='© <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: {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
Loading…
Add table
Reference in a new issue