diff --git a/.env.example b/.env.example index 44051e0..73c2cfd 100644 --- a/.env.example +++ b/.env.example @@ -3,14 +3,21 @@ DATABASE_URL="postgresql://postgres:password@localhost:5432/sos-pet" # Next Auth NEXTAUTH_URL="http://localhost:3000" -NEXTAUTH_SECRET="nextauthsecret" +NEXTAUTH_SECRET="nextauth-secret" -# Next Auth Providers -GOOGLE_CLIENT_ID="" -GOOGLE_CLIENT_SECRET="" +# Next Auth Google Sign-In +GOOGLE_CLIENT_ID="google-client-id" +GOOGLE_CLIENT_SECRET="google-client-secret" +# Next Auth Email magic link Sign-In +# https://resend.com/changelog/smtp-service +EMAIL_HOST="smtp.resend.com" +EMAIL_PORT="465" +EMAIL_USER="resend" +EMAIL_PASSWORD="resend-api-key" +EMAIL_FROM="noreply@sos-pet.org" # Google Maps -NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="" +NEXT_PUBLIC_GOOGLE_MAPS_API_KEY="google-maps-api-key" # Docker related variables, not used on Next.js POSTGRES_DB="sos-pet" diff --git a/.github/workflows/lint-type-check-and-build.yaml b/.github/workflows/lint-type-check-and-build.yaml index 0577526..c6b57b1 100644 --- a/.github/workflows/lint-type-check-and-build.yaml +++ b/.github/workflows/lint-type-check-and-build.yaml @@ -9,6 +9,7 @@ on: jobs: lint-type-check-and-build: runs-on: ubuntu-latest + name: Lint, Type Check, and Build steps: - name: Checkout code uses: actions/checkout@v4 @@ -20,7 +21,10 @@ jobs: cache: "yarn" - name: Setup .env file - run: echo "${{ secrets.DOT_ENV_FILE_CONTENT }}" > .env + # All the environment variables in .env.example are invalid, + # therefore the application will not be functional. But, it is enough + # for linting, type checking, and building. + run: cat .env.example > .env - name: Install dependencies run: yarn diff --git a/README.md b/README.md index 82d961e..d5e12aa 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,6 @@ Com o SOS Pet, as pessoas que resgatam animais de enchentes podem rapidamente en 1. Instale as dependências 1. Crie um arquivo `.env` baseando se no `.env.example` 1. Assegure se de preencher todas as variáveis ambiente. -1. Suba o baco de dados: `docker compose up -d` +1. Suba o baco de dados: `docker-compose up -d` 1. Rode as migrations: `npx prisma migrate dev` 1. Rode o projeto com o script `dev` disponível no `package.json` diff --git a/package.json b/package.json index 3e07f45..5987d5c 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "next": "^14.2.1", "next-auth": "^4.24.6", "next-themes": "^0.3.0", + "nodemailer": "^6.9.13", "react": "18.2.0", "react-dom": "18.2.0", "react-hook-form": "^7.51.4", @@ -55,6 +56,7 @@ "devDependencies": { "@types/eslint": "^8.56.2", "@types/node": "^20.11.20", + "@types/nodemailer": "^6.4.15", "@types/react": "^18.2.57", "@types/react-dom": "^18.2.19", "@typescript-eslint/eslint-plugin": "^7.1.1", diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 0e50f5b..c07cd8f 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -61,6 +61,8 @@ export default function About() { transformar um momento de crise em uma oportunidade para fazer a diferença na vida de um animal. Juntos, podemos salvar vidas e construir um futuro mais seguro para nossos amigos de quatro patas. + Além disso, esse projeto tem o código fonte aberto e disponível para colaboração: + https://github.com/emiliosheinz/sos-pet

diff --git a/src/app/page.tsx b/src/app/page.tsx index 3643be0..b99578b 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,7 +2,6 @@ import { Card } from "~/components/card/"; import { SearchInput } from "~/components/search-input"; import { api } from "~/trpc/react"; -import { Filters } from "~/components/filters"; import Fuse from "fuse.js"; import { useMemo } from "react"; import { Skeleton } from "~/components/ui/skeleton"; @@ -10,16 +9,6 @@ import { useDebouncedState } from "~/hooks/use-debouced-state"; import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { FiInfo } from "react-icons/fi"; -const menus = [ - { - label: "Disponibilidade", - items: [ - { label: "Com vagas", checked: true }, - { label: "Sem vagas", checked: false }, - ], - }, -]; - export default function Home() { const { data, isLoading } = api.shelter.findAll.useQuery(); const [searchTerm, setSearchTerm] = useDebouncedState("", 300); diff --git a/src/app/signin/_components/AuthenticationProviders.tsx b/src/app/signin/_components/AuthenticationProviders.tsx new file mode 100644 index 0000000..0ca0066 --- /dev/null +++ b/src/app/signin/_components/AuthenticationProviders.tsx @@ -0,0 +1,84 @@ +"use client"; + +import { getProviders } from "next-auth/react"; +import { useEffect, useMemo, useState } from "react"; +import { SignInProviderButton } from "./SignInProviderButton"; +import { EmailProviderForm } from "./EmailProviderForm"; +import { Loader2 } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { FiAlertTriangle } from "react-icons/fi"; + +type GetProvidersState = "idle" | "loading" | "success" | "error"; + +type AuthenticationProvidersProps = { + callbackUrl?: string; +}; + +/** + * Prevents multiple calls to getProviders during one session + */ +let cachedProviders: Awaited> = null; +async function getCachedProviders() { + if (!!cachedProviders) return cachedProviders; + return (cachedProviders = await getProviders()); +} + +export function AuthenticationProviders({ + callbackUrl, +}: AuthenticationProvidersProps) { + const [providers, setProviders] = + useState>>(); + const [getProvidersState, setGetProvidersState] = + useState("idle"); + + useEffect(() => { + setGetProvidersState("loading"); + getCachedProviders() + .then((providers) => { + setProviders(providers); + setGetProvidersState("success"); + }) + .catch(() => { + setGetProvidersState("error"); + }); + }, []); + + const [emailProvider, otherProviders] = useMemo(() => { + if (!providers) return [null, null]; + const email = providers.email; + const other = Object.values(providers).filter( + (provider) => provider.id !== "email", + ); + return [email, other]; + }, [providers]); + + if (["loading", "idle"].includes(getProvidersState)) { + return ; + } + + if (getProvidersState === "error") { + return ( + + + Erro ao carregar provedores de login + + Por favor, entre em contato com o nosso suporte em  + sospet.suport@gmail.com + + + ); + } + + return ( +
+ {otherProviders?.map((provider) => ( + + ))} + {!!emailProvider && } +
+ ); +} diff --git a/src/app/signin/_components/EmailProviderForm.tsx b/src/app/signin/_components/EmailProviderForm.tsx new file mode 100644 index 0000000..83c3720 --- /dev/null +++ b/src/app/signin/_components/EmailProviderForm.tsx @@ -0,0 +1,72 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { Loader2 } from "lucide-react"; +import { signIn } from "next-auth/react"; +import { useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "~/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormItem, + FormMessage, +} from "~/components/ui/form"; +import { Input } from "~/components/ui/input"; + +const formSchema = z.object({ + email: z.string().email("Por favor, insira um e-mail válido"), +}); + +export function EmailProviderForm() { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { email: "" }, + }); + const [isLoading, setIsLoading] = useState(false); + + const onSubmit = async ({ email }: z.infer) => { + setIsLoading(true); + await signIn("email", { email }); + setIsLoading(false); + }; + + return ( +
+ +
+ + ou + +
+
+ ( + + + + + + + )} + /> + + + + + ); +} diff --git a/src/app/signin/_components/SignInProviderButton.tsx b/src/app/signin/_components/SignInProviderButton.tsx new file mode 100644 index 0000000..60670c8 --- /dev/null +++ b/src/app/signin/_components/SignInProviderButton.tsx @@ -0,0 +1,30 @@ +"use client"; + +import { type ClientSafeProvider, signIn } from "next-auth/react"; +import Image from "next/image"; + +type SignInProviderButtonProps = { + provider: ClientSafeProvider; + callbackUrl: string; +}; + +export function SignInProviderButton({ + provider, + callbackUrl, +}: SignInProviderButtonProps) { + return ( + + ); +} diff --git a/src/app/signin/_components/SigninProviderButton.tsx b/src/app/signin/_components/SigninProviderButton.tsx deleted file mode 100644 index cea7aa3..0000000 --- a/src/app/signin/_components/SigninProviderButton.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client"; - -import { signIn } from "next-auth/react"; -import Image from "next/image"; - -type SigninProviderButtonProps = { - provider: { id: string; name: string }; - callbackUrl: string; -}; - -export function SigninProviderButton({ - provider, - callbackUrl, -}: SigninProviderButtonProps) { - return ( - - ); -} diff --git a/src/app/signin/layout.tsx b/src/app/signin/layout.tsx new file mode 100644 index 0000000..9f71fc4 --- /dev/null +++ b/src/app/signin/layout.tsx @@ -0,0 +1,9 @@ +import { type PropsWithChildren } from "react"; + +export default function SignInVerifyLayout({ children }: PropsWithChildren) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/signin/page.tsx b/src/app/signin/page.tsx index ded9df1..3ccace1 100644 --- a/src/app/signin/page.tsx +++ b/src/app/signin/page.tsx @@ -1,47 +1,47 @@ -import { getProviders } from "next-auth/react"; import { redirect } from "next/navigation"; import { getServerAuthSession } from "~/server/auth"; -import { SigninProviderButton } from "./_components/SigninProviderButton"; import Image from "next/image"; import { Suspense } from "react"; import { Loader2 } from "lucide-react"; +import { Alert, AlertDescription, AlertTitle } from "~/components/ui/alert"; +import { FiAlertTriangle } from "react-icons/fi"; +import { AuthenticationProviders } from "./_components/AuthenticationProviders"; -type SigninPageProps = { +type SignInPageProps = { searchParams: Record; }; -export default async function SigninPage({ searchParams }: SigninPageProps) { +export default async function SignInPage({ searchParams }: SignInPageProps) { const session = await getServerAuthSession(); if (session) { redirect(searchParams.callbackUrl ?? "/"); } - const providers = [{ id: "google", name: "Google" }]; + const hasError = !!searchParams.error; return ( -
- }> - Logo -

- Em decorrência das enchentes que afetaram o estado do Rio Grande do - Sul, estamos gerenciando as necessidades de abrigos de animais vítimas - do desastre. -

-

- Antes de cadastrar um abrigo você precisa fazer login{" "} - com uma das opções abaixo: -

-
- {Object.values(providers).map((provider) => ( - - ))} -
-
-
+ }> + Logo +

+ Em decorrência das enchentes que afetaram o estado do Rio Grande do Sul, + estamos gerenciando as necessidades de abrigos de animais vítimas do + desastre. +

+

+ Antes de cadastrar um abrigo você precisa fazer login com + uma das opções abaixo: +

+ {hasError && ( + + + Erro ao tentar realizar o login. + + Por favor, tente novamente com outra opção disponível. + + + )} + +
); } diff --git a/src/app/signin/verify/page.tsx b/src/app/signin/verify/page.tsx new file mode 100644 index 0000000..ccf79ce --- /dev/null +++ b/src/app/signin/verify/page.tsx @@ -0,0 +1,19 @@ +import { FaRegCheckCircle } from "react-icons/fa"; + +export default function SignInVerifyPage() { + return ( + <> +
+ +

+ E-mail enviado com sucesso +

+

+ Enviamos um e-mail com um link para você verificar sua conta. Por + favor, verifique sua caixa de entrada para continuar com o + processo de login. +

+
+ + ); +} diff --git a/src/components/card/index.tsx b/src/components/card/index.tsx index 903b2fd..7ec48e6 100644 --- a/src/components/card/index.tsx +++ b/src/components/card/index.tsx @@ -22,35 +22,15 @@ export function Card({ shelter }: Props) { return ( - -
-

{shelter.name}

-
- {shelter.facebook && ( - } - label="Facebook" - /> - )} - - {shelter.instagram && ( - } - label="Instagram" - /> - )} - - {shelter.twitter && ( - } - label="Twitter" - /> - )} -
-
+ +

{shelter.name}

+ {availableVacancies > 0 ? ( +

+ Vagas disponíveis: {availableVacancies} +

+ ) : ( +

Vagas esgotadas

+ )} - {availableVacancies > 0 ? ( -

- Vagas disponíveis: {availableVacancies} -

- ) : ( -

- Vagas esgotadas -

- )} +
+ {shelter.facebook && ( + } + label="Facebook" + /> + )} + + {shelter.instagram && ( + } + label="Instagram" + /> + )} + + {shelter.twitter && ( + } + label="Twitter" + /> + )} +
); diff --git a/src/components/footer/index.tsx b/src/components/footer/index.tsx index 9db2522..0b5862a 100644 --- a/src/components/footer/index.tsx +++ b/src/components/footer/index.tsx @@ -1,6 +1,6 @@ import Image from "next/image"; import Link from "next/link"; -import { FaInstagram } from "react-icons/fa"; +import { FaInstagram, FaGithub } from "react-icons/fa"; export function Footer() { return ( @@ -15,6 +15,13 @@ export function Footer() { />
+ + sos-pet + Políticas de privacidade +
diff --git a/src/components/header/index.tsx b/src/components/header/index.tsx index e270b43..432dba3 100644 --- a/src/components/header/index.tsx +++ b/src/components/header/index.tsx @@ -2,32 +2,41 @@ import Image from "next/image"; import { Nav } from "./nav"; import { Sidebar } from "./sidebar"; import Link from "next/link"; +import { Button } from "../ui/button"; export function Header() { + const renderMainSection = () => ( +
+
+ + Logo + +
+
    +
  • + +
  • +
  • + +
  • +
+
+ ); + return (
-
+
-
-
- - Logo - -
-
    -
  • - Home -
  • -
  • - Sobre -
  • -
-
+ {renderMainSection()}
diff --git a/src/components/header/nav/index.tsx b/src/components/header/nav/index.tsx index 6ba732c..629c1b6 100644 --- a/src/components/header/nav/index.tsx +++ b/src/components/header/nav/index.tsx @@ -1,5 +1,4 @@ "use client"; -import { useRouter } from "next/navigation"; import { Button } from "~/components/ui/button"; import { useSession, signOut } from "next-auth/react"; @@ -14,39 +13,36 @@ import { import Link from "next/link"; import { User } from "../user"; -import { CiCircleChevDown } from "react-icons/ci"; - export function Nav() { const { data: session } = useSession(); - const router = useRouter(); return (