Descubriendo Codember y sus Desafíos de Programación

John SerranoJohn Serrano
6 noviembre - 2023
Desarrollo-web

¿Qué es codember y sus desafíos de programación?

Codember es un emocionante sitio web que presenta desafíos semanales de programación. Cada semana, puedes sumergirte en la resolución de estos desafíos utilizando tu lenguaje de programación favorito. Pero eso no es todo, también puedes descubrir secretos ocultos y acumular valiosos puntos.

La dinámica de los desafíos y sus secretos:

  • Resuelve Rápido, Gana Más: La dinámica es sencilla; cuanto más rápido resuelvas un desafío, más puntos acumulas. La velocidad es la clave para ascender en el ranking.

  • Secretos por Descubrir: No solo se trata de los desafíos, también hay secretos esperando ser descubiertos. Cada secreto desvelado te recompensa con una cantidad de puntos, lo que agrega un giro emocionante a la experiencia.

  • Premios: Existe la posibilidad que se den algunos premios a los primeros puestos del ranking.

Codember fue creado por Miguel Ángel Durán (midudev):

Codember fue creado por Miguel Angel Duran, también conocido como midudev. Su pasión por la programación y su deseo de ofrecer una plataforma desafiante para la comunidad de desarrolladores han dado vida a este emocionante proyecto.

Como funciona Codember

Al acceder al siguiente enlace, puedes iniciar sesión con tu cuenta de GitHub. Una vez que hayas iniciado sesión, tendrás acceso a una serie de comandos, como help, ls, cd, submit, hint, clear, y más.

Para empezar con el primer desafío, simplemente ejecuta el comando ls. Luego, utiliza cat CHALLENGE_01.txt para ver el primer desafío y obtener una explicación detallada de la dinámica y las instrucciones para completarlo.

Este entorno interactivo te permite sumergirte en emocionantes desafíos y desbloquear tu creatividad. ¡No dudes en comenzar tu aventura y demostrar tus habilidades!

** El reto **
Un espía está enviando mensajes encriptados.

Tu misión es crear un programa que nos ayude a buscar patrones...

Los mensajes son palabras separadas por espacios como este:
gato perro perro coche Gato peRRo sol

Necesitamos que el programa nos devuelva el número de veces que aparece cada palabra en el mensaje, independientemente de si está en mayúsculas o minúsculas.

El resultado será una cadena de texto con la palabra y el número de veces que aparece en el mensaje, con este formato:
gato2perro3coche1sol1

¡Las palabras son ordenadas por su primera aparición en el mensaje!

** Más ejemplos: **
llaveS casa CASA casa llaves -> llaves2casa3
taza ta za taza -> taza2ta1za1
casas casa casasas -> casas1casa1casas1

** Cómo resolverlo **
1. Resuelve el mensaje que encontrarás en este archivo: https://codember.dev/data/message_01.txt

2. Envía tu solución con el comando "submit" en la terminal, por ejemplo así:
submit perro3gato3coche1sol1

Soluciones

Solución del primer desafío

El primer desafío lo resolví con TypeScript, pero ten en cuenta que puedes abordarlos con tu lenguaje de programación favorito. Estoy emocionado por seguir actualizando en la medida de lo posible esta publicación con los próximos desafíos y sus respectivas explicaciones.

¡Hablemos sobre la solución! Si ya has explorado el primer desafío, es posible que hayas descubierto algunos secretos, solo por mencionar ya encontre los 3 primeros secretos, actualmente esta es mi posición y mi cantidad de puntos:

Tu puntuación actual::
Posición: 367 (9201017 puntos)
Retos: 1/1 - Secretos: 3/3

Para el primer desafío debemos devolver el texto con la cantidad de veces que se repite ese texto todo pegado, ejemplos:

llaveS casa CASA casa llaves -> llaves2casa3
taza ta za taza -> taza2ta1za1
casas casa casasas -> casas1casa1casas1

Mi solución:

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

export type WordCount = {
  [word: string]: number
}

async function messages() {
  try {
    const filePath = resolve('./message_01.txt')
    const wordList = await readFile(filePath, { encoding: 'utf8' })

    let wordsCount: WordCount = {}
    let resultMessage = ''

    wordList.split(' ').forEach(word => {
      const wordSanitized = word.toLowerCase()
      wordsCount[wordSanitized] !== undefined ? (wordsCount[wordSanitized] += 1) : (wordsCount[wordSanitized] = 1)
    })

    const words = Object.keys(wordsCount)

    for (const word of words) {
      resultMessage += `${word}${wordsCount[word]}`
    }

    console.log(resultMessage)
  } catch (error) {
    console.log('This is error -> ', error)
  }
}

;(async () => {
  await messages()
  // resultado: murcielago15leon15jirafa15cebra6elefante15rinoceronte15hipopotamo15ardilla15mapache15zorro15lobo15oso15puma2jaguar14tigre10leopardo10gato12perro12caballo14vaca14toro14cerdo14oveja14cabra14gallina10pato10ganso10pavo10paloma10halcon11aguila11buho11colibri9canario8loro8tucan8pinguino7flamenco7
})()

Importación de Módulos:

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

En esta sección, se importan dos módulos de Node.js. readFile se utiliza para leer archivos de forma asíncrona, y resolve se usa para resolver rutas de archivos.

Definición de un Tipo:

export type WordCount = {
  [word: string]: number
}

Se define un tipo llamado WordCount, que es un objeto donde las claves son palabras (cadena) y los valores son números que representan la cantidad de veces que aparece esa palabra.

Función Asíncrona messages():

const filePath = resolve('./message_01.txt')
const wordList = await readFile(filePath, { encoding: 'utf8' })

let wordsCount: WordCount = {}
let resultMessage = ''

wordList.split(' ').forEach(word => {
  const wordSanitized = word.toLowerCase()
  wordsCount[wordSanitized] !== undefined ? (wordsCount[wordSanitized] += 1) : (wordsCount[wordSanitized] = 1)
})

La función resolve se utiliza para obtener la ruta absoluta del archivo message_01.txt. Luego, readFile se usa para leer el contenido del archivo en formato UTF-8 y se almacena en la variable wordList.

Se inicializan dos variables: wordsCount, que será un objeto de tipo WordCount para contar las palabras, y resultMessage, que contendrá el resultado de la operación.

Se divide el contenido del archivo en palabras individuales utilizando split(' '). Luego, se recorre cada palabra y se realiza un conteo de la frecuencia de cada palabra en wordsCount. Se convierten las palabras a minúsculas para que el conteo no sea sensible a mayúsculas y minúsculas.

Generación del Resultado:

const words = Object.keys(wordsCount)
for (const word of words) {
  resultMessage += `${word}${wordsCount[word]}`
}

console.log(resultMessage)

Se obtienen todas las palabras únicas del objeto wordsCount y se genera un resultado que combina la palabra y su frecuencia.

Finalmente, se imprime el resultado en la consola.

;(async () => {
  await messages()
})()

Al final del código, se define una función autoejecutable que llama a messages() para iniciar el proceso. Esto asegura que la función se ejecute inmediatamente al cargar el script.

Puedes encontrar el código completo en el siguiente enlace código desafío 01 si gustas puedes darle estrellita al repositorio.

Solución del segundo desafío

Para el segundo desafío debemos desarrollar un mini compilador que tome una cadena de texto y devuelva otra cadena de texto con el resultado cumpliendo unas condiciones, ejemplos:

- Entrada: "##*&"
- Salida esperada: "4"
- Explicación: Incrementa (1), incrementa (2), multiplica (4), imprime (4).

- Entrada: "&##&*&@&"
- Salida esperada: "0243"
- Explicación: Imprime (0), incrementa (1), incrementa (2), imprime (2), multiplica (4), imprime (4), decrementa (3), imprime (3).

El defafío completo lo pueden encontrar en el siguiente link.

Mi solución:

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

async function miniCompiler() {
  let text = ''

  try {
    const filePath = resolve('./message_02.txt')
    text = await readFile(filePath, { encoding: 'utf8' })
  } catch (error) {
    console.log('This is error -> ', error)
  }

  const listSymbols = text.split('')

  const operations: Record<string, (count: number) => number> = {
    '&': (count: number) => count,
    '#': (count: number) => count + 1,
    '@': (count: number) => count - 1,
    '*': (count: number) => count * count,
  }

  let count = 0
  let result = ''

  listSymbols.forEach(symbol => {
    count = operations[symbol](count)

    result += symbol === '&' ? count : ''
  })

  console.log(result)
}

;(async () => {
  await miniCompiler() // result -> 024899455
})()

Explicación del código, la primera parte del código es lo mismo que hicimos con el desafío anterior así que no voy a entrar al detalle de eso. Revisemos el resto de la solución.

const listSymbols = text.split('');

Se convierte el contenido del archivo en una lista de símbolos utilizando el método split('').

const operations: Record<string, (count: number) => number> = {
  '&': (count: number) => count,
  '#': (count: number) => count + 1,
  '@': (count: number) => count - 1,
  '*': (count: number) => count * count,
};

Se define un objeto operations que asigna símbolos a funciones que realizan operaciones en un contador, se podría haber resuelto con condicionales if pero creo que de esta manera queda más escalable el código y menos largo.

let count = 0;
let result = '';

Se inicializan las variables count y result.

listSymbols.forEach(symbol => {
  count = operations[symbol](count);
  result += symbol === '&' ? count : '';
});

Se itera sobre cada símbolo en listSymbols. Se aplica la operación correspondiente al símbolo utilizando el objeto operations. Se construye el resultado concatenando el valor actual de count solo si el símbolo es &.

(async () => {
  await miniCompiler(); // result -> 024899455
})();

Al final se imprime el resultado final en la consola.

En resumen, el código lee el contenido de un archivo, realiza operaciones basadas en símbolos en un contador y luego imprime el resultado en la consola. La lógica de las operaciones está definida en el objeto operations.

Puedes encontrar el código completo en el siguiente enlace código desafío 02 si gustas puedes darle estrellita al repositorio.

Solución del tercer desafío

En el tercer desafío nos encontramos con un desafío del Cifrado Espía debemos analizar una lista de políticas y claves de cifrado que se encuentran en un archivo y crear un programa que devuelva la clave inválida número 42 (de todas las claves inválidas, la 42ª en orden de aparición). Ejemplo.

2-4 f: fgff
4-5 z: zzzsg
1-6 h: hhhhhh

Cada línea indica, separado por :, la política de la clave y la clave misma.

La política de la clave especifica el número mínimo y máximo de veces que un carácter dado debe aparecer para que la clave sea válida. Por ejemplo, 2-4 f significa que la clave debe contener f al menos 2 veces y como máximo 4 veces.

Sabiendo esto, en el ejemplo anterior, hay 2 claves válidas:

La segunda clave, zzzsg, no lo es; contiene 3 veces la letra z, pero necesita al menos 4. Las primeras y terceras claves son válidas: contienen la cantidad adecuada de f y h, respectivamente, según sus políticas.

El defafío completo lo pueden encontrar en el siguiente link.

Mi solución:

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

async function encryptionPolicies() {
  let passwords = ''

  try {
    const filePath = resolve('./encryption_policies.txt')
    passwords = await readFile(filePath, { encoding: 'utf8' })
  } catch (error) {
    console.log('This is error read file -> ', error)
  }

  const listPasswords = passwords.split('\n')

  const INDEXPASSWORD = 42 // or 13
  let invalidCountPassword = 0

  listPasswords.forEach(text => {
    const mainText = text.split(' ')
    const [minWord, maxWord] = mainText[0].split('-')
    const word = mainText[1].slice(0, 1)
    const passwordValue = mainText[2]

    const occurrences = passwordValue.match(new RegExp(word, 'g'))?.length ?? 0

    if (!(occurrences >= Number(minWord) && occurrences <= Number(maxWord))) {
      invalidCountPassword++
      if (invalidCountPassword === INDEXPASSWORD) {
        console.log('invalid password is -> ' + passwordValue)
      }
    }
  })
}

;(async () => {
  await encryptionPolicies() // result -> bgamidqewtbus
})()

Explicación del código, la primera parte del código es lo mismo que hacemos en los desafíos anteriores así que no voy a entrar al detalle de eso. Revisemos el resto de la solución.

const listPasswords = passwords.split('\n')

const INDEXPASSWORD = 42 // or 13
let invalidCountPassword = 0

Dividimos el contenido del archivo en líneas y creamos un array llamado listPasswords con las contraseñas. Definimos la posición de la contraseña que se imprimirá en caso de ser inválida, y se inicializa el contador de contraseñas inválidas.

listPasswords.forEach(text => {
  const mainText = text.split(' ')
  const [minWord, maxWord] = mainText[0].split('-')
  const word = mainText[1].slice(0, 1)
  const passwordValue = mainText[2]

  const occurrences = passwordValue.match(new RegExp(word, 'g'))?.length ?? 0

  if (!(occurrences >= Number(minWord) && occurrences <= Number(maxWord))) {
    invalidCountPassword++
    if (invalidCountPassword === INDEXPASSWORD) {
      console.log('invalid password is -> ' + passwordValue)
    }
  }
})

Iteramos sobre cada línea de contraseñas y realizamos la verificación, dividimos la línea de texto en componentes, como la cantidad mínima y máxima de ocurrencias de una letra (minWord y maxWord), la letra objetivo (word), y la contraseña misma (passwordValue). Utilizamos una expresión regular para contar las ocurrencias de la letra en la contraseña.

Al final verificamos si el número de ocurrencias de la letra en la contraseña está fuera del rango permitido. Si es así, incrementa el contador de contraseñas inválidas y, si el índice de contraseña inválida coincide con INDEXPASSWORD, imprime la contraseña en la consola.

Entrando en detalle que hacemos con la expresión regular: new RegExp(word, 'g'):

new RegExp: Creamos una instancia de un objeto RegExp, que representa una expresión regular.

word: La variable que contiene la letra objetivo.

'g': La bandera de la expresión regular que significa “global”. Esto indica que la búsqueda de coincidencias debe realizarse en toda la cadena, no solo en la primera ocurrencia.

En resumen, esta parte crea una expresión regular que busca globalmente la letra específica en la cadena.

passwordValue.match(...):

passwordValue: La cadena de texto que se va a analizar en busca de coincidencias.

match(...): Una función de las cadenas de JavaScript que busca coincidencias en la cadena con la expresión regular especificada. La función match devuelve un array de todas las coincidencias encontradas o null si no se encuentra ninguna coincidencia.

Es importante destacar que cuando usamos una cadena como argumento para match, solo encuentra la primera ocurrencia de la cadena en el texto. Para buscar todas las ocurrencias, especialmente si la cadena contiene caracteres especiales, es más común y útil utilizar una expresión regular con la bandera 'g' para indicar búsqueda global.

Puedes encontrar el código completo en el siguiente enlace código desafío 03 si gustas puedes darle estrellita al repositorio.

Solución del cuarto desafío

En el cuarto desafío nos dicen los siguiente “Hackers dañan sistema de archivos” debemos analiza la lista de nombres de archivos y sus checksums donde debemos buscar en el archivo real número 33 (de todos los archivos reales, el 33º en orden de apareción) y envía su checksum con submit. Ejemplos:

+ Nombre del archivo: xyzz33-xy
+ Resultado: ✅ Real (El checksum es válido)

+ Nombre del archivo: abcca1-ab1
+ Resultado: ❌ Falso (El checksum debería ser b1, es incorrecto)

+ Nombre del archivo: abbc11-ca
+ Resultado: ❌ Falso (El checksum debería ser ac, el orden es incorrecto)

El defafío completo lo pueden encontrar en el siguiente link.

Mi solución:

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

async function filesQuarantine() {
  let readFilesQuarantine = ''

  try {
    const filePath = resolve('./files_quarantine.txt')
    readFilesQuarantine = await readFile(filePath, { encoding: 'utf8' })
  } catch (error) {
    console.log('This is error read file -> ', error)
  }

  const listFileNames = readFilesQuarantine.split('\n')

  const INDEXFILENAME = 33
  let validUnchecksumCount = 0

  for (const fileName of listFileNames) {
    const [text, unchecksum] = fileName.split('-')

    const notRepeatWord = text.split('').filter((character, _, self) => self.indexOf(character) === self.lastIndexOf(character)).join('')

    if (unchecksum === notRepeatWord) {
      validUnchecksumCount++
      if (validUnchecksumCount === INDEXFILENAME) {
        console.log('valid unchecksum -> ' + unchecksum)
        break
      }
    }
  }
}

;(async () => {
  await filesQuarantine() // result -> O2hrQ
})()

Explicación del código, la primera parte del código es lo mismo que hacemos en los desafíos anteriores así que no voy a entrar al detalle de eso. Revisemos el resto de la solución.

const listFileNames = readFilesQuarantine.split('\n');

const INDEXFILENAME = 33;
let validUnchecksumCount = 0;

Lo primero que hacemos es dividir la cadena readFilesQuarantine en un array de nombres de archivo, donde cada nombre de archivo está separado por un carácter de nueva línea (\n).

Declaramos dos variables INDEXFILENAME y validUnchecksumCount. La variable INDEXFILENAME se usa para almacenar el índice del nombre de archivo que estamos buscando. La variable validUnchecksumCount se usa para realizar un seguimiento de la cantidad de unchecksums válidos que hemos encontrado.

const [text, unchecksum] = fileName.split('-');

const notRepeatWord = text.split('').filter((character, _, self) => self.indexOf(character) === self.lastIndexOf(character)).join('')

Iteramos sobre el array listFilesNames, donde cada iteración asigna el nombre de archivo actual a la variable fileName. dividimos el nombre de archivo actual en dos partes: la parte de texto y la parte de unchecksum. La parte de texto es todo lo anterior al guion (-), y la parte de unchecksum es todo lo que está después del guion (-).

Creamos una nueva cadena llamada notRepeatWord eliminando todos los caracteres duplicados de la parte de texto. El método split('') divide la parte de texto en un array de caracteres. El método filter() filtra el array de caracteres para que solo incluya caracteres que no estén repetidos y esto lo logramos gracias a indexOf y lastIndexOf. El método join('') une el array de caracteres filtrado nuevamente en una cadena.

if (unchecksum === notRepeatWord) {
  validUnchecksumCount++;
  if (validUnchecksumCount === INDEXFILENAME) {
    console.log('valid unchecksum -> ' + unchecksum);
    break;
  }
}

Por último verificamos si la parte de unchecksum es igual a la cadena notRepeatWord. Si son iguales, entonces el unchecksum es válido, y la variable validUnchecksumCount se incrementa. Si la variable validUnchecksumCount es igual a la variable INDEXFILENAME, entonces el bucle se termina y el unchecksum válido se imprime en la consola.

Puedes encontrar el código completo en el siguiente enlace código desafío 04 si gustas puedes darle estrellita al repositorio.

Solución del quinto desafío

En el quinto desafío tenemos el problema final que dice lo siquiente: Finalmente los hackers han conseguido acceder a la base de datos y la han dejado corrupta. Pero parece que han dejado un mensaje oculto en la base de datos. ¿Podrás encontrarlo?

Ejemplos:

Entrada: 1a421fa,alex,[email protected],18,Barcelona
Resultado: ✅ Válido

Entrada: 9412p_m,maria,[email protected],22,CDMX
Resultado: ❌ Inválido (id no es alfanumérica, sobra el _)

Entrada: 494ee0,madeval,[email protected],,
Resultado: ✅ Válido (age y location son opcionales)

Entrada: 494ee0,madeval,twitch.tv,22,Montevideo
Resultado: ❌ Inválido (email no es válido)

El defafío completo lo pueden encontrar en el siguiente link.

Mi solución:

import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'

async function databaseAttacked() {
  let readFilesDbAttacked = ''

  try {
    const filePath = resolve('./database_attacked.txt')
    readFilesDbAttacked = await readFile(filePath, { encoding: 'utf8' })
  } catch (error) {
    console.log('This is error read file -> ', error)
  }

  const listFileUsers = readFilesDbAttacked.split('\n')

  const messageHidden: string[] = []

  listFileUsers.forEach(user => {
    const [id, username, email, ...rest] = user.split(',')

    const regexAlfaNumber = new RegExp(/^[A-Za-z0-9]+$/, 'g')
    const regexEmail = new RegExp(/^\w+@[a-z]+\.[a-z]{2,3}/, 'g')
    const regexNumber = new RegExp(/^[0-9]+$/, 'g')
    const regexLocation = new RegExp(/^[A-Za-z \s]+$/, 'g')

    let age: string | null = null
    let location: string | null = null
    if (rest.length === 2) {
      age = rest[0]
      location = rest[1]
    } else {
      age = null
      location = rest[0]
    }

    let invalidUser = false

    if (!id || !username || !email) {
      invalidUser = true
    } else if (!id.match(regexAlfaNumber) || !username.match(regexAlfaNumber) || !email.match(regexEmail)) {
      invalidUser = true
    } else if ((age && !age.match(regexNumber)) || (location && !location.match(regexLocation))) {
      invalidUser = true
    }

    if (invalidUser) {
      messageHidden.push(username.charAt(0))
    }
  })

  console.log(messageHidden.join(''))
}

;(async () => {
  await databaseAttacked() // result -> youh4v3beenpwnd
})()

Explicación del código, la primera parte del código es lo mismo que hacemos en los desafíos anteriores así que no voy a entrar al detalle de eso. Revisemos el resto de la solución.

const listFileUsers = readFilesDbAttacked.split('\n');

Dividimos el contenido por cada salto de línea creando un array llamado listFileUsers, donde cada elemento es una línea del archivo.

const [id, username, email, ...rest] = user.split(',');

const regexAlfaNumber = new RegExp(/^[A-Za-z0-9]+$/, 'g');
const regexEmail = new RegExp(/^\w+@[a-z]+\.[a-z]{2,3}/, 'g');
const regexNumber = new RegExp(/^[0-9]+$/, 'g');
const regexLocation = new RegExp(/^[A-Za-z \s]+$/, 'g');

En la primera línea de código, se divide cada línea del archivo en campos separados por comas y se asignan a las variables id, username, email y rest. rest contendrá cualquier otro dato que esté presente después de email, en este caso puede ser age o location.

Se definen expresiones regulares para validar diferentes tipos de datos: alfanuméricos, direcciones de correo electrónico, números y ubicaciones.

let age: string | null = null;
let location: string | null = null;

if (rest.length === 2) {
  age = rest[0];
  location = rest[1];
} else {
  age = null;
  location = rest[0];
}

Dependiendo de la longitud de rest (los datos adicionales después de email), se asigna el age y el location o solo la ubicación. Si la longitud es 2, se asume que la edad está presente.

let invalidUser = false;

if (!id || !username || !email) {
  invalidUser = true;
} else if (!id.match(regexAlfaNumber) || !username.match(regexAlfaNumber) || !email.match(regexEmail)) {
  invalidUser = true;
} else if ((age && !age.match(regexNumber)) || (location && !location.match(regexLocation))) {
  invalidUser = true;
}

if (invalidUser) {
  messageHidden.push(username.charAt(0));
}

Se realiza una serie de comprobaciones para determinar si un usuario es inválido. Esto incluye verificar la existencia de id, username y email, y luego aplicar las expresiones regulares para garantizar que cumplan con ciertos criterios.

Si se determina que el usuario es inválido, se agrega la inicial del nombre de usuario al array messageHidden.

Puedes encontrar el código completo en el siguiente enlace código desafío 05 si gustas puedes darle estrellita al repositorio.

¡Fue un placer abordar todos estos desafíos contigo! Espero que las soluciones proporcionadas hayan cumplido tus expectativas. Quedo a tu disposición para futuras consultas. ¡Nos vemos el próximo año con más desafíos!

Conclusiones

Codember es el lugar perfecto para poner a prueba tus habilidades de programación, aprender nuevos conceptos y competir con otros entusiastas de la programación. ¡Únete a la comunidad de Codember y acepta el desafío!

Quiero expresar mi sincero agradecimiento a Miguel Ángel Durán (midudev) por ser una fuente constante de inspiración que nos impulsa a seguir aprendiendo y a mejorar como desarrolladores web. Su dedicación y pasión nos motivan a nunca dejar de aprender y a alcanzar nuevos niveles de excelencia en nuestra carrera. ¡Gracias, midudev, por ser un faro en nuestro viaje de desarrollo web!

Soy John Serrano ingeniero de software con más de 7 años de experiencia. Me especializo en la creación de experiencias digitales de alto impacto. Entusiasta de las tecnologías web: JavaScript, TypeScript, Node.js, Docker, Firebase, React, etc. Me puedes encontrar en las siguientes redes sociales:

Apoyo

Estoy muy feliz de que disfrutes del contenido de johnserrano.co, si te gusta lo que lees y quieres respaldar mi trabajo, puedes realizar una donación a través de Tarjeta de crédito o PSE. Además, estoy disponible para recibir tu apoyo por correo electrónico en [email protected] si prefieres otras opciones. Tu apoyo ayuda a mantener este proyecto en marcha. ¡Gracias por tu apoyo!

Apoyo no monetario

Otra manera de ayudarme es difundiéndolo de boca en boca! Si consideras que el contenido que comparto en johnserrano.co puede ser valioso para tus amig@s y compañer@s, te invito a compartirlo en Twitter, LinkedIn o en la plataforma que prefieras. Tu recomendación puede marcar la diferencia. ✨

No hay un amor más grande que el dar la vida por los amigos. Juan 15:13

¿Te gusta lo que lees?

Suscríbete

Otros artículos