/letmeask

Aplicação que me garantiu uma bolsa de estudos no Ignite! Desenvolvida durante a NLW com outras funcionalidades adicionadas por mim.

Primary LanguageTypeScript

Letmeasklogo

Letmeask

Visite o site aqui
Tecnologias   |    Projeto
Dia 1   |    Dia 2   |    Dia 3   |    Dia 4   |    Dia 5
Minhas alterações no projeto

✨ Tecnologias

Esse projeto foi desenvolvido com as seguintes tecnologias:

💻 Projeto

O letmeask é um app desenvolvido durante a NLW que permite que alguém realizando lives crie uma sala para receber perguntas, tendo maior interação com o usuário.

Dia 1

Configuração de ambiente

Foi dado inicio ao aplicativo React com yarn create react-app letmeask --template typescript

Tópicos que eu considerei importantes no dia:

  • Introdução ao Typescript
  • Introdução ao Firebase
  • Resumo sobre SPA
  • Benefícios na utilizando de functions no React
  • Introdução a Hooks

Dia 2

Páginas iniciais e autenticação

Uma bomba de conteúdo! Começamos com uma simples página com HTML e SCSS e então partimos para o início do método de autenticação.

Primeiro fizemos a integração do react-router-dom para navegar pelas páginas, então fizemos o método de login pelo google utilizando o firebase. Com uma função assíncrona async function signInWithGoogle() definimos o provedor como const provider = firebase.auth.GoogleAuthProvider() e definimos o resultado como const result = await auth.signInWithPopup(provider)

Nesse ponto, se o cliente concluir a autenticação já temos um result com várias informações, que permite a gente a criar uma estrutura condicional para o nosso código, então podemos checar se o cliente tem foto, nome.

  if (result.user) {
  const { displayName, photoURL, uid} = result.user;
  
  if(!displayName || !photoURL) {
    throw new Error('Missing information from Google Account.')
  };

  setUser({
    id: uid,
    name: displayName,
    avatar: photoURL
  });
  };

Se o usuário não possuir um nome e foto de perfil, a função retornará um erro com uma string, se ele tem todos os dados, a função vai "setar" o estado com os dados novos do usuário.

Para manter os dados do usuário caso ele atualize a página foi utilizado o useEffect, foi usado dentro da função um observador para garantir que o objeto Auth não esteja em um estado intermediário (como inicialização) ao identificar o usuário atual.

Por fim, foi feito uma refatoração do código, todo o AuthContext foi passado para um arquivo TSX próprio, e foi criado também o arquivo UseAuth.js para simplificar o uso de hooks

import { useContext } from 'react';
import { AuthContext } from '../contexts/AuthContext';

export function useAuth() {
  const value = useContext(AuthContext)
  return value
};

Dia 3

Criando novas salas e novas perguntas

Para criar uma nova sala no database do firebase precisamos da função firebase.database().ref() que retorna uma referência, que é uma localização dentro da database do Firebase. assim podemos escrever :

  const roomRef = database.ref('rooms');

  const firebaseRoom = await roomRef.push({
        title: newRoom,
        authorId: user?.id,
      })

Dessa maneira, estamos passando para a database na localização "rooms" um objeto contendo title e authorId que foi passado pelo usuário.

Depois de feito o scss da Sala, foi criado um component RoomCode para apenas pegar o código.

Para ter acesso ao código utilizamos o useParams do react-router-dom e para poder guardar os valores precisamos definir uma constante:

   const params = useParams();

porém, precisamos definir quais são os parametros que queremos receber nessa rota.

então definimos:

  type RoomParams = {
    id: string;
  }

então fica:

const params = useParams<RoomParams>();

agora a função sabe quais parametros vai receber.

e agora usamos o componente criado e passamos esse parametro como uma prop:

<RoomCode code={params.id} />

Criamos uma pequena função para o botão de copiar o numero da sala

function copyRoomCodeToClipboard() {
    navigator.clipboard.writeText(props.code)
  }

O próximo passo é fazer o botão de enviar questões funcionar.

Para isso definidos um novo estado chamado newQuestion

const [newQuestion, setNewQuestion] = useState('')

e importamos também o user que guardamos com a função signInWithGoogle()

agora é só checar se a perguntava enviada tem mesmo algum conteúdo.

if (newQuestion.trim() === "") {
      return
    }

e verificar se o usuário está logado.

if(!user) {
  throw new Error('You must be logged in');
}

se passar por essas condições, definimos um objeto com os dados da nova pergunta e os dados do usuário.

const question = {
      content: newQuestion,
      author: {
        name: user?.name,
        avatar: user.avatar,
      },
      isHighlighted: false,
      isAnswered: false
    }

isHighlighted e isAnswered com valores booleanos para no futuro termos um controle da interface de acordo com seus valores.

então passamos esse objeto para a database com

await database.ref(`rooms/${roomId}/questions`).push(question)

E para consumir questões da database do Firebase vamos utilizar o hook useEffect(() => {}, []) para buscar no firebase os dados das perguntas.

useEffect(() => {
    const roomRef = database.ref(`rooms/${roomId}`);

    roomRef.on('value', room => {
      console.log(room.val());
    })
  }, []) 

Este evento .on irá disparar uma vez com os dados iniciais armazenados neste local e, em seguida, disparar novamente cada vez que os dados forem alterados.

O console.log(room.val()) vai nos devolver um objeto com authorId:string, questions:object, title:string.

então definimos o tipo de objeto

//Record para tipar objetos, e dentro de <> fica o tipo da chave
type FirebaseQuestions = Record<string, {
  author: {
    name: string;
    avatar: string;
  }
  content: string; 
  isAnswered: boolean;
  isHighlighted: boolean;
}>

então definimos a constante:

const firebaseQuestions: FirebaseQuestions = databaseRoom.questions;

e transformamos esse objeto em um vetor com Object.entries();

const parsedQuestions = Object.entries(firebaseQuestions)

dessa forma o objeto {"name": "Eduardo", "cidade": "belém"} vai retornar [["name", "eduardo"], ["cidade","belém"]]

então podemos utilizar o .map(value => {}) tratando o value como um vetor, fazendo uma desestruturação sabendo que o primeiro valor é a chave e o segundo valor é o valor dessa chave. [key, value]

const parsedQuestions = Object.entries(firebaseQuestions).map(([key, value]) => {
        return {
          id: key,
          content: value.content,
          author: value.author,
          isHighlighted: value.isHighlighted,
          isAnswered: value.isAnswered,
        }
      })

agora que temos um [] que contém um object com as perguntas, precisamos salvar isso em algum estado.

Criamos um para as perguntas e um para o titulo.

const [questions,setQuestions] = useState<Question[]>([])
const [title, setTitle] = useState('')

e definimos o tipo do estado das perguntas:

type Question = {
  id:string;
  author: {
    name: string;
    avatar: string;
  }
  content: string; 
  isAnswered: boolean;
  isHighlighted: boolean;
}

e passamos os valores para os estados ainda dentro de <codeuseEffect()

setTitle(databaseRoom.title)
setQuestions(parsedQuestions)

Agora basta usarmos essas informações na interface.

Dia 4

Estrutura das perguntas HTML e CSS

Foi feito um componente Question com HTML e CSS para servir como a div que vai conter as perguntas. Esse componente foi importado para Room.tsx onde foi feito um .map() nele.

{questions.map(question => {
	      return (
		<Question
		  key={question.id}
		  content={question.content}
		  author={question.author}
		/>
	      )
	    })}

Agora todo item contido em questions vai retornar como um componente Question.

Criando o hook useRoom

Criamos uma função chamada useRoom() e agora temos que trazer todas as funcionalidades que vão ser utilizadas tanto na página do usuário quanto na página do admin . Então pegamos a parte de carregamento das questões

Passamos as funções de useEffect() do arquivo Room.tsx, suas tipagens, os estados:questions e title, e então exportamos dessa função useRoom() apenas as perguntas e os titulos, para que possamos importar de volta no Room.tsx.

return { questions, title }. 

Mas para que o firebase consiga localizar aonde queremos fazer a referência no banco de dados é necessário do roomId, que é os pedaços dinâmicos do URL da página que colocamos como placeholder no path, precisamos passar essa rota para o useRoom(), então : useRoom(roomId: string)

Agora quando usarmos o hook na page Room.tsx passamos o roomId, que é o id da pagina no Route que foi inserido pelo handleCreateRoom na page NewRoom.tsx

const { questions, title } = useRoom(roomId)

Feito isso, o código na page Room.tsx já parece muito mais limpo e podemos aproveitar essa funcionalidade na página do admin!

Criamos a page AdminRoom.tsx copiando toda a page Room.tsx, retiramos todo o form e adicionamos o componente Button no header.

No componente Button foi passado um type { isOutlined?: boolean } e nas props da function agora podemos passar ({isOutlined = false, ...props})

Então colocamos uma condicional no className:

className={`button ${isOutlined? 'outlined' : ''}`}

E agora caso isOutlined seja true a classe outlined também é aplicada.

Criando funcionalidade de Like

Depois de feito o CSS do botão do like, é criado na page Room.tsx uma função assíncrona que recebe a questionId e a informação se já foi dado o like ou não.

handleLikeQuestion(questionId:string, likeId: string | undefined) {}

essa função vai fazer o push para a database com o authorId.

Primeiro fazemos uma condição para saber se o usuário já deu o like ou não.

if (likeId) {
await database.ref(`rooms/${roomId}/questions/${questionId}/likes/${likeId}`).remove()
}

Caso retorne false então selecionamos a localização na database.

await database.ref(`rooms/${roomId}/questions/${questionId}/likes`)

e enviamos os dados nessa localização

await database.ref(`rooms/${roomId}/questions/${questionId}/likes`).push({
	authorId: user?.id,
})

Para contarmos os números de likes é necessário voltarmos no nosso hook useRoom()

Adicionamos a linha likeCount: Object.values(value.likes ?? {}).length para que a gente receba a quantidade de objetos com o authorId que foi passado anteriormente e o ?? {} serve para caso não tenha nenhum.

E para acompanhar se o usuário deu like ou não precisamos pegar seus dados de autenticação com useAuth()

const { user } = useAuth() 

Agora que temos o user.id adicionamos a linha

likeId: Object.entries(value.likes ?? {}).find(([ key , like ]) => like.authorId === user?.id)?.[0]

.find() percorre o array até encontrar uma condição que satisfaça o que passamos para ele, retornando seu conteúdo.

?.[0] retorna nulo caso ele não ache nada na posição 0.

Então pegamos cada um dos like e verificamos se o authorId é igual ao user?.id.

Agora adicionamos cada um no QuestionType informando seus tipos.

type QuestionType = {
  id:string;
  author: {
    name: string;
    avatar: string;
  }
  content: string; 
  isAnswered: boolean;
  isHighlighted: boolean;
  likeCount: number;
  likeId: string | undefined;
}

E atualizamos também a tipagem no FirebaseQuestions

type FirebaseQuestions = Record<string, {
  author: {
    name: string;
    avatar: string;
  }
  content: string; 
  isAnswered: boolean;
  isHighlighted: boolean;
  likes: Record<string, {
    authorId:string;
  }>
}>

para remover todos os event listener utilizamos

return () => {
roomRef.off('value')
}

E no final adicionamos user?.id no array de dependências, pois essa variável não está sendo definida dentro do useEffect()

Então fica:

useEffect(() => {
    const roomRef = database.ref(`rooms/${roomId}`);

    roomRef.on('value', room => {
      const databaseRoom = room.val();
      const firebaseQuestions: FirebaseQuestions = databaseRoom.questions ?? {};

      const parsedQuestions = Object.entries(firebaseQuestions).map(([key, value]) => {
	return {
	  id: key,
	  content: value.content,
	  author: value.author,
	  isHighlighted: value.isHighlighted,
	  isAnswered: value.isAnswered,
	  likeCount: Object.values(value.likes ?? {}).length,
	   likeId: Object.entries(value.likes ?? {}).find(([key, like]) => like.authorId === user?.id)?.[0],
	}
      })

      setTitle(databaseRoom.title)
      setQuestions(parsedQuestions)
    })

    return () => {
      roomRef.off('value')
    }
  }, [roomId, user?.id]) 

Então agora no botão adicionamos uma classe para caso likeId retorne o Id do usuário.

className={`like-button ${question.likeId ? 'liked' : ''}`}

E a função onClick:

onClick={() => handleLikeQuestion(question.id, question.likeId)}

Pronto! a funcionalidade de dar like está completa.

Remoção de pergunta sem o modal

Precisamos criar um botão dentro de que recebe a função handleDeleteQuestion(question.id) E essa função assíncrona que recebe uma string:

async function handleDeleteQuestion(questionId: string) {
    if (window.confirm('Tem certeza que deseja excluir essa pergunta?')) {
      await database.ref(`rooms/${roomId}/questions/${questionId}`).remove();
    }
}

Se window.confirm() retornar true, ele acha a pergunta com a questionId na .ref() passada e remove a pergunta com .remove()

Para encerrar a sala criamos uma função para fazer o update do objeto no banco de dados para conter a data que a sala foi encerrada e enviamos o usuário para a tela inicial do app, então:

 const history = useHistory()

 async function handleEndRoom () {
    database.ref(`rooms/${roomId}`).update({
      endedAt: new Date()
    })
    history.push('/')
 }

E para evitar que pessoas entrem na sala colocamos no handleJoinRoom() do Home.tsx a seguinte condicional :

if (roomRef.val().endedAt) {
      alert('Room already closed');
      return;
    }

Fim do dia 4! Ufa!

Dia 5

Criação dos botões

Muito HTML e CSS para as criações dos botões handleCheckQuestionAsAnswered e handleHighlightQuestion

Foi atualizado as tipagens do component Questions

	type QuestionProps = {
	  content: string;
	  author: {
	    name: string;
	    avatar: string;
	  }
	  children?: ReactNode;
	  isAnswered?: boolean;
	  isHighlighted?: boolean;
	}

E então exporta esse componente recebendo false como a prop default

export function Question({
  content,
  author,
  isAnswered = false,
  isHighlighted = false,
  children,
}

E adicionado suas respectivas classes de acordo com o valor desses estados]

 className={cx(
    'question',
    { answered: isAnswered},
    { highlighted: isHighlighted && !isAnswered}
  )}

Hospedando o projeto

O hosting é feito com o próprio hosting do Firebase.

O primeiro passo é instalar o Firebase Tools

npm install -g firebase-tools

E então fazer o login no google

firebase login

Ir para a pasta do projeto e executar este comando no diretório raiz do seu app:

firebase init

E precisamos dizer quais features estamos usando do Firebase, no nosso caso: Realtime Database e Hosting. Escolhemos usar um projeto já existente e selecionamos o public diretory : build, que é o arquivo que o create-react-app gera os arquivos para produção. Perguntam se é uma SPA e respondemos que sim.

Agora que temos o firebase.json e os outros arquivos na nossa aplicação estamos prontos para por em produção.

Rodamos a build do projeto

yarn build

e iniciamos o deploy.

firebase deploy

E a aplicação já está funcionando online.

Minhas alterações no projeto

Criar a página de lista de salas

Primeiro criei um estado para armazenar esses dados.

const [rooms, setRooms] = useState<RoomType>([])

para criar a página de lista de salas, criei o arquivo RoomList.tsx e usei o hook useEffect() para carregar os dados necessários para renderizar a sala.

Peguei a referência do meu banco de dados.

const dbRef = database.ref(`rooms`);

Então li todos os dados e retornei eles em um array contendo vários objetos.

dbRef.once('value', rooms => {
      const dbRoom: object = rooms.val() ?? {}
      const parsedRooms = Object.entries(dbRoom).map(([key,value]) => {
	return {
	  roomId: key,
	  title: value.title,
	  roomIsOpen: value.roomIsOpen
	}
      })
      setRooms(parsedRooms)
})

Fiz as devidas tipagens de como eu queria esse objeto dentro do array.

type RoomType = {
  roomId:string;
  title: string;
  roomIsOpen?: boolean;
}[]

Agora só preciso utilizar .map() para me retornar as salas no formato que eu quero, porém, também quero mostrar algo caso não tenha nenhuma sala disponível.

Então crio a seguinte condicional:

{rooms.length !== 0 ? 
	rooms.map((item: any) => {
	  return(
	    <div 
	      className={`room-item-div ${item.roomIsOpen? '': 'closed'}`} 
	      onClick={() => handleGoToRoom(item.roomId, item?.roomIsOpen)}
	      key={item.roomId} >
		  {item.title}
	    </div>)}) 
     : (
	<div className="empty-list"> 
	  <h1>Não temos salas no momento</h1>
	  <img src={emptyImg} alt="Empty Room" />
	</div>)}

Agora, se rooms contém algum resultado vai aparecer:

Room List

E se não tiver resultado:

Empty Room List

Adicionando animações CSS

Foi adicionado utilizando o site Animista screen-capture