Executando verificação de segurança...
12

[Pitch] Sorteio de Amigo Secreto: Sem API, Só Hash

No final do ano de 2023 eu topei participar de um Amigo Secreto da minha familia que, como cada um mora distante do outro, foi sorteado online.
A organizadora nos enviou links onde vimos os nomes de quem tiramos.

E algo me chamou muita atenção...

O site pareceu uma aplicação tão simples: Uma plataforma onde você informa uma lista de nomes que terão pares sorteados e links individuais para exibição dos nomes.

E eis o que me chamou a atenção: a elegância de uma solução simples.

Então, decidi recriar a mesma aplicação...

A ideia

Sempre que penso na construção de uma aplicação, uma das primeiras coisas que penso é: como vou estruturar e armazenar os dados?

E a resposta para a geração desses links é: não vou!

O sorteado não precisa de muito mais do que o nome da pessoa que ele sorteou, certo? Então, é só o que vamos mandar para ele!

E quanto ao dono do sorteio?

Bem... ele talvez precise ver novamente alguma informação daquele Amigo Secreto. Então, podemos persistir de uma forma que só ele veja, assim, não precisando de nenhuma centralização de dados.

O projeto

A aplicação teria 4 telas

  • Inicial: onde o usuário veria a lista de seus sorteios já criados.
  • Formulário de Criação do Amigo Secreto.
  • Tela de visualização: Onde o usuário veria cada nome atrelado a um botão de "Enviar Link".
  • Tela de revelação: onde o sorteado veria o nome da pessoa que sorteou.

Escolhi usar NextJS e usar o IndexedDB para persistência local.

1 - Formulário de Criação

Sim, vamos começar com ele.

Pensei um pouco sobre como seria para adicionar multiplos nomes e fiquei dividido entre um input baseado em Tags usando algum separador:
Input Em Tags

E entre multiplos inputs seguidos por um botão de "adicionar outro" como abaixo:
Multiplos Inputs

E no fim acabei optando pela segunda opção.

E agora, após o usuário apertar para enviar, a aplicação precisa gerar o sorteio e gerar os hashs para o link.

Então, precisamos de um método que que sorteie e outro que gere os hashs.
Esse foi o resultado:

type Pair = {
  name: string;
  hash: string;
}

// Método chamado no onSubmit
const sortPairs = (names: string[]): Pair[] => {
  const _pairs: Pair[] = [];
  const _names = [...names];

  // Reordena aleatoriamente
  _names.sort(() => Math.random() - 0.5);

  
  for (let i = 0; i < _names.length; i++) {
      const name = _names[i].trim();
      
      // Monta o par com a próxima index
      const nextIndex = (i != _names.length - 1) ? i + 1 : 0;
      const pair = _names[nextIndex].trim();
      
      // Chama o método de encriptação
      const hash = encryptNamePair(name, pair);
      _pairs.push({
          name,
          hash
      });
  }

  return _pairs;
};

E o encryptNamePair, que recebe dois nomes, só precisa concatená-los e converter e base64.

O formato antes da conversão ficaria mais ou menos assim: "Nome1-Nome2", para que a gente saiba a quem pertence o link.

const encryptNamePair = (n1: string, n2: string) => {
    const namesJoint = `${n1.replace(/ /g, '+').trim()}-${n2.replace(/ /g, '+').trim()}`;
    return btoa(unescape(encodeURIComponent(namesJoint)));
}

E quanto a persistência local, decidi usar o IndexedDB do próprio navegador.

Pra isso, criei uma classe IndexedDBManager para gerenciar as chamadas.
Não vou perder muito tempo explicando, porque é tudo muito padrão aqui.
Mas o código ficou assim:

// IndexedDBManager.ts
export class IndexedDBManager {
  // Informações do banco local
  private dbName: string = "RcSecretSantaDB";
  private dbVersion: number = 1;
  private db: IDBDatabase | null = null;
  private readonly tableName: string = "secretSantas";

  constructor() { }

  // Método de conexão
  public async connect (): Promise<void> {
    return new Promise((resolve, reject) => {
      const request = indexedDB.open(this.dbName, this.dbVersion);

      request.onerror = () => {
        reject(new Error("Erro ao abrir o banco de dados."));
      };

      request.onsuccess = (event) => {
        this.db = (event.target as IDBOpenDBRequest).result;
        resolve();
      };

      request.onupgradeneeded = (event) => {
        const db = (event.target as IDBOpenDBRequest).result;
        if (!db.objectStoreNames.contains(this.tableName)) {
          db.createObjectStore(this.tableName, { keyPath: "id" });
        }
      };
    });
  }

  // Método de inserção
  public async addEditItem (item: any): Promise<void> {
    return new Promise((resolve, reject) => {
      this.connect().then(() => {
        if (!this.db) {
          reject(new Error("Banco de dados não conectado."));
          return;
        }

        const transaction = this.db.transaction([this.tableName], "readwrite");
        const objectStore = transaction.objectStore(this.tableName);

        const request = objectStore.put(item);

        request.onsuccess = () => {
          resolve();
        };

        request.onerror = () => {
          reject(new Error("Erro ao adicionar/editar item no banco de dados."));
        };
      })
    });
  }
}

Então, no método onSumbit do formulário eu só preciso chamar assim:

const dbManager = new IndexedDBManager();
await dbManager.addEditItem({ id: uuidv4(), title, pairs });

E pronto. Nossos Amigos Secretos já podem ser salvos!

2 - Home - Listagem de Amigos Secretos

Ela só precisa de três coisas:

  • Uma lista dos Amigos Secretos
  • Um botão para excluir um item
  • Um link para visualizar um item em específico

Para buscar os dados do IndexedDB só é necessário o método abaixo:

// IndexedDBManager.ts
public async getItems (): Promise<any[]> {
  return new Promise((resolve, reject) => {
    this.connect().then(() => {
      if (!this.db) {
        reject(new Error("Banco de dados não conectado."));
        return;
      }

      const transaction = this.db.transaction([this.tableName], "readonly");
      const objectStore = transaction.objectStore(this.tableName);

      const request = objectStore.getAll();

      request.onsuccess = (event) => {
        const result = (event.target as IDBRequest).result;
        resolve(result || []);
      };

      request.onerror = () => {
        reject(new Error("Erro ao obter itens do banco de dados."));
      };
    });
  });
}

E para o caso de o usuário querer excluir:

// IndexedDBManager.ts
public async deleteItem (id: string): Promise<void> {
  return new Promise((resolve, reject) => {
    this.connect().then(() => {
      if (!this.db) {
        reject(new Error('Banco de dados não conectado.'));
        return;
      }

      const transaction = this.db.transaction([this.tableName], 'readwrite');
      const objectStore = transaction.objectStore(this.tableName);

      const request = objectStore.delete(id);

      request.onsuccess = () => {
        resolve();
      };

      request.onerror = () => {
        reject(new Error('Erro ao excluir item do banco de dados.'));
      };
    });
  });
}

3 - Visualização de Amigo Secreto

Essa é a minha favorita.

Ela precisa capturar o dado do IndexedDB e apresentar os nomes dos participantes atrelados à um link.

A minha ideia foi criar um botão de compartilhamento onde haveria uma mensagem explicando basicamente o contexto da mensagem e fornecendo o link.

Vamos lá... Primeiramente, precisamos recuperar o item.
A tela recebe a ID gerada pela tela de criação e envia para o método getItemById que vai retornar o Objeto de SecretSanta.

// IndexedDBManager.ts
public async getItemById <T>(id: string): Promise<T | null> {
  return new Promise((resolve, reject) => {
    this.connect().then(() => {
      if (!this.db) {
        reject(new Error('Banco de dados não conectado.'));
        return;
      }

      const transaction = this.db.transaction([this.tableName], 'readonly');
      const objectStore = transaction.objectStore(this.tableName);
      const request = objectStore.get(id);

      request.onsuccess = (event) => {
        const result = (event.target as IDBRequest).result;
        resolve(result || null);
      };

      request.onerror = () => {
        reject(new Error('Erro ao obter item do banco de dados.'));
      };
    });
  });
}

Depois disso, adicionei a listagem dos nomes e criei um componente SharedButton que é bem simples:

// ShareButton.tsx
interface IShareButtonProps {
  title: string;
  text: string;
  className?: string;
}

const ShareButton = ({ title, text, className }: IShareButtonProps) => {
  const handleShare = () => {
    if (navigator.share) {
      navigator.share({
        title,
        text
      });
    } else {
      console.error('Web Share API não é suportada neste navegador.');
    }
  };

  return (
    <button className={className} onClick={handleShare}>{title}</button>
  );
};

E o adicionei no [id].tsx assim:


const View = () => {
  const [data, setData] = useState<SecretSanta>();
  const hostLink = window.location.origin;
  // ...

  return (
    <>
      {
        data.pairs.map((pair) => (
          // ....
          <ShareButton
              title="Enviar"
              text={
                `Você foi adicionado ao Amigo Secreto: "${data.title}"!\nE aqui está o seu link:\n${hostLink}}/${pair.hash}`
              }
          />
        ))
      }
    </>
  );
};

O que fica mais ou menos assim:

Mensagem Whatsapp

4 - A tela de revelação

Ela é a mais simples de todas.
Como o link contem o hash, só precisamos recuperá-lo, desencriptar e então separar os nomes.

E fazemos isso com o método abaixo:

const decryptNamePair = (encodedStr: string) => {
    const decodedString = decodeURIComponent(escape(atob(encodedStr)));
    if (!/(.*)-(.*)/.test(decodedString)) {
        return null;
    }

    return decodedString.replace(/\+/g, " ").split("-");
};

// [hash].tsx
const [name1, name2] = decryptNamePair(hash);

Após receber os nomes, tudo o que precisamos é exibi-los na tela de uma forma amigável.
E eu escolhi fazer assim:

Resultado Amigo Secreto

Conclusão

Esse foi um projeto rápido que me deixou bem satisfeito por tamanha simplicidade.
Projetos assim, me provam que nem tudo precisa ser complexo ou mega elaborado.

O melhor sistema é o que funciona! Não se esqueça disso!

Depois de completo, eu ainda gastei um tempinho refinando e adicionando tradução, mas acho que não tem muito mais o que mexer nele.

Fique à vontade para olhar usar ou ver o código fonte:

Acesse - Amigo Secreto

Código Fonte

Jabá:

Veja esse e outros posts no meu blog:

Blog Racoelho

Carregando publicação patrocinada...
3

Só um detalhe (porque sou um cara chato e pedante): atob e btoa são funções para converter de/para Base64. E Base64 não é um algoritmo de hash. Base64 é reversível (vc consegue voltar para a string original), mas um algoritmo de hash não é.

Ah, e Base64 não é criptografia, por isso "encrypt" e "decrypt" acabam não sendo bons nomes. No fundo vc só está fazendo uma conversão de dados (sim, Base64 só converte/codifica (que é diferente de criptografar) os bytes para um subconjunto do ASCII). Talvez "encode" e "decode" faça mais sentido, pois esta é a terminologia padrão para algoritmos de codificação de dados = como é o caso do Base64 (inclusive, muitas API's usam nomes como encodeBase64 e decodeBase64, por exemplo).

Sei que parece frescura, mas acho importante a gente dar os nomes certos para as coisas. Isso ajuda muito na hora de programar. Sem contar que nossa área já sofre demais com nomenclatura não-padronizada e conceituação errada das coisas, e propagar esses pequenos erros (que no longo prazo vão se acumulando, até ficarem fora de controle) é algo que deveria ser evitado.


Pra completar, a função unescape está obsoleta e a documentação da MDN diz para usar decodeURIComponent ou decodeURI no lugar.

Ou seja, ao fazer unescape(encodeURIComponent(namesJoint)), no fundo vc está fazendo o mesmo que decodeURIComponent(encodeURIComponent(namesJoint)), que na prática é o mesmo que simplesmente usar namesJoint diretamente. A diferença é que unescape aceita sequências de 4 dígitos hexadecimais e decodeURIComponent não, mas como encodeURIComponent só gera sequências com 2 dígitos, na prática toda essa volta que vc fez não fará diferença, e bastaria fazer simplesmente btoa(namesJoint).

O mesmo vale para escape, que segundo a documentação, também está obsoleto, sendo recomendado o uso de encodeURIComponent ou encodeURI no lugar. Ou seja, decodeURIComponent(escape(atob(encodedStr))) seria equivalente a decodeURIComponent(encodeURIComponent(atob(encodedStr))) e portanto também é desnecessário, podendo fazer apenas atob(encodedStr).

2

Muito bom!

Sobre os métodos: realmente... sempre misturo encryt e encode 😪...
Eu já passei por isso vária vezes, mas sempre misturo um com o outro haha

E não, não parece nenhum pouco com frescura pra mim.

E sobre o segundo ponto: realmente, está tanto depreciado quanto desnecessário kkkkk
Vous subir essas mudanças no próximo commit.

Muito obrigado!

2

Top d+!

Ia fazer um fork e mandar um PR mas fiquei com preguiça hahahaha.
Senti falta de poder copiar o link via web mesmo.

Acredito que adicionando esse trecho de código abaixo no componente ShareButton naquela parte que você colocou um else já vai ajudar muito:

navigator.clipboard.writeText(text)
  .then(() => {
     // GA Event -> que eu vi que vc está emitindo
     // Mostrar o toast
   })
   .catch(err => { 
     // Fazer alguma coisa com o erro, um toast, log, sla
     console.error(err.message);
  });
1

Hahaha, muito obrigado!

Eu já coloquei aqui na minha TO-DO list.
Acho que vai ser melhor mesmo...
Vou aproveirar e dar copy-paste do seu mesmo kkkk

1

Primeiramente, muito bom!
Com certeza usaria em um amigo secreto e essa ideia de usar apenas hashs é incrível!

Tenho apenas duas sugestões:

  1. Pequena validação nos nomes, testei com emojis e ao acessar o link deu erro 404
  2. Botão de copiar link ao invés de compartilhar pelo sistema, é uma ideia interessante mas a simplicidade na minha opinião é melhor :)
1

Hmmmm, perfeito!
Eu realmente não testei nenhum caso de caracteres que não alfa-numéricos 😅, vou corrigir.

E quanto ao botão, realmente... acho que ficaria mais simples.
Escolhi esse modo pensando no uso do celular mesmo.
Mas o que você disse faz sentido!