Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions docs/raffle-linkedin.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
## Sorteo de merchandising desde reacciones de LinkedIn

Este documento recoge el análisis, las limitaciones y la propuesta de implementación para añadir a la web un apartado que permita "sortear" (elegir aleatoriamente) un usuario de LinkedIn que haya reaccionado a un post concreto.

---

## Resumen rápido

- Objetivo: crear una página que permita introducir la URL de un post de LinkedIn y, al pulsar "Sortear", devuelva aleatoriamente un usuario que haya reaccionado al post.
- Restricción clave: la API oficial de LinkedIn permite recuperar acciones sociales (reacciones, comentarios) solo cuando el post es del miembro autenticado o cuando el miembro autenticado está mencionado. No es posible, en general, consultar las reacciones de cualquier post público sin permisos especiales.
- Recomendación MVP: implementar primero un modo "manual" (front-end) para elegir aleatoriamente entre una lista pegada por el organizador. En 2ª fase, integrar OAuth + backend para automatizar con LinkedIn cuando sea posible.

---

## Hallazgos técnicos (apoyados en la documentación oficial)

- Endpoint relevante: `GET https://api.linkedin.com/v2/socialActions/{postURN}` — devuelve resumen de acciones sociales del post.
- Condición de acceso habitual: el post debe ser propiedad del miembro autenticado o el miembro autenticado debe estar mencionado en el post. En caso contrario la API devuelve 403.
- Las APIs de comunidad/marketing tienen límites de uso y scopes que requieren registrar una app y posiblemente pasar por revisión de LinkedIn.
- Alternativas no oficiales (scraping, servicios de terceros) existen pero presentan riesgos de TOS y fragilidad.

---

## Opciones de diseño (evaluación)

1) OAuth + Backend (recomendado para automatización completa)
- Pros: automático, UX superior.
- Contras: requiere registrar app, manejar OAuth, alojar backend serverless o servidor, posible revisión por LinkedIn y permisos adicionales.
- Notas: en muchos casos solo funcionará si el autor del post autoriza la app.

2) Frontend-only con PKCE
- Pros: evita backend para el flujo de autorización.
- Contras: CORS, tokens en cliente y mismas limitaciones de acceso a posts ajenos.

3) Modo manual (MVP)
- Pros: inmediato, funciona sin credenciales ni backend.
- Contras: trabajo manual por parte del organizador (copiar/pegar la lista de participantes).

4) Scraping o terceros (no recomendado)
- Pros: puede recuperar datos sin consentimiento explícito del autor.
- Contras: riesgo legal/terms of service, fragilidad y coste.

---

## Arquitectura propuesta

MVP (rápido, se puede integrar hoy mismo en `ghspain.github.io`):
- Frontend React (nueva página) en `src/components/RafflePage.tsx`.
- UI: campo para URL del post, modo selección: "Manual" (textarea/CSV) y botón "Sortear".
- Lógica: parsear lista de participantes, validaciones mínimas, seleccionar aleatorio y mostrar resultado.
- No requiere backend; funciona en GitHub Pages.

Automatización completa (fase 2):
- Backend serverless (Vercel/Netlify/Cloudflare) con endpoints:
- OAuth callbacks y almacenamiento seguro de tokens.
- `POST /api/raffle` que recibe `postUrl` y devuelve participante aleatorio usando `socialActions/{postURN}`.
- Frontend: botones para iniciar OAuth y solicitar sorteo al backend.
- Requisitos: `client_id`, `client_secret`, `REDIRECT_URI`, scopes apropiados y posible revisión de app por LinkedIn.

---

## Contrato (inputs/outputs)

- Endpoint (backend) — `POST /api/raffle`
- Input JSON:
- `postUrl` (string): URL del post de LinkedIn.
- `mode` ("api" | "manual")
- `participants` (Array) — solo en modo manual: lista de participantes { name?, profileUrl?, id? }
- Output JSON:
- `winner`: { name?, profileUrl?, urn?, avatar? }
- `count`: número de participantes
- `seed?`: opcional, para reproducibilidad
- `error?`: mensaje en caso de fallo

- Frontend (UI):
- Input: URL del post o textarea con lista manual.
- Output visible: tarjeta con ganador y botón para abrir su perfil.

---

## Casos límite y verificaciones

- Post no perteneciente al token autenticado → la API devuelve 403. Mostrar mensaje claro y fallback al modo manual.
- Post sin reacciones → informar (no hay participantes).
- Reacciones paginadas → backend debe manejar paginación para recopilar la lista completa.
- Rate limits → aplicar backoff/retries y exponer límites al usuario.
- Privacidad → no exponer tokens en cliente; registrar el mínimo requerido y borrar logs sensibles.

---

## Pasos técnicos para implementar el MVP manual (rápido)

1. Añadir componente React `src/components/RafflePage.tsx` con:
- Campo `postUrl` (solo informativo)
- Textarea para pegar participantes (una línea por perfil o JSON)
- Botón "Sortear" que valida y selecciona aleatorio
- Mostrar resultado con link al perfil
2. Añadir enlace a `Navigation.tsx` para acceder a la nueva página
3. Añadir test mínimo en `src/__tests__` o usar `App.test.tsx` como referencia
4. Hacer build y comprobar `npm run build`

---

## Implementaciones realizadas (MVP)

He implementado un MVP manual y adapté la navegación para que el sorteo sea una página separada. Cambios realizados en el repositorio:

- `src/components/RaffleSection.tsx`
- Nuevo componente React que contiene:
- campo `postUrl` (informativo),
- `textarea` para pegar la lista manual de participantes (una línea por participante),
- botón "Sortear (manual)" que selecciona aleatoriamente un participante y muestra el resultado.

- `src/components/index.ts`
- Exportado `RaffleSection` desde el índice de componentes para poder importarlo desde otras páginas.

- `src/pages/RafflePage.tsx`
- Nueva página dedicada que renderiza únicamente `RaffleSection`. Accesible en la ruta `/raffle`.

- `src/App.tsx`
- Modificado para renderizar la página independiente cuando `window.location.pathname === '/raffle'`. Si no, renderiza la web normal.

- `src/components/Navigation.tsx`
- El enlace `Sorteo` ahora apunta a `/raffle` y permanece oculto por defecto con `style={{ display: 'none' }}`. Cuando lo actives en la navegación, llevará a la página independiente.

- `docs/raffle-linkedin.md`
- Actualizado (este archivo) para incluir el resultado del trabajo y la documentación del MVP.

Resultado de la verificación:

- Ejecuté `npm run build` después de los cambios y la compilación fue exitosa. Aparecieron advertencias de ESLint ya existentes en otros componentes, pero no hubo errores de compilación.

Cómo activar la navegación / reactivar el enlace "Sorteo":

1. Editar el archivo `src/components/Navigation.tsx` y eliminar `style={{ display: 'none' }}` del enlace:

- Antes:

```tsx
<AnchorNav.Link href="/raffle" style={{ display: 'none' }}>Sorteo</AnchorNav.Link>
```

- Después:

```tsx
<AnchorNav.Link href="/raffle">Sorteo</AnchorNav.Link>
```

2. (Opcional, recomendado) Alternativa más flexible: renderizar el enlace condicionalmente usando una variable de entorno (por ejemplo `process.env.REACT_APP_ENABLE_RAFFLE === 'true'`). Esto permite activarlo sin tocar el código fuente, solo cambiando la variable y recompilando.

Notas y próximos pasos recomendados:

- Si quieres automatizar la obtención de participantes desde LinkedIn, la siguiente fase es preparar el backend/OAuth (ver la sección "Pasos para automatizar con LinkedIn (fase 2)").
- Puedo ahora:
- hacer el cambio para activar el enlace mediante una variable de entorno (rápido), o
- pulir estilos del componente para que encaje con el resto del sitio, o
- preparar el esqueleto del backend serverless para iniciar el flujo OAuth.


## Pasos para automatizar con LinkedIn (fase 2)

1. Registrar app en LinkedIn Developers (obtener `client_id` y `client_secret`)
2. Definir scopes necesarios (ej. lectura de socialActions — revisar docs y requerimientos de LinkedIn)
3. Implementar backend serverless:
- Endpoint para iniciar OAuth
- Callback que almacena token de manera segura
- Endpoint `POST /api/raffle` que acepta `postUrl`, obtiene `postURN`, llama `GET /v2/socialActions/{postURN}` y recupera reacciones
- Manejar paginación y transformar reacciones a lista de participantes
- Seleccionar aleatorio y devolver resultado
4. Desplegar backend y actualizar frontend para llamar al endpoint
5. Documentar el flujo y permisos necesarios

---

## Requerimientos / Preguntas para el usuario

- ¿Quieres que implemente primero el MVP manual integrado en este repo? (recomendado — muy rápido y se puede usar ya en GitHub Pages)
- Si prefieres la integración automática: ¿vas a registrar la app de LinkedIn o quieres que te guíe para hacerlo? Necesitaré `client_id`, `client_secret` y la `REDIRECT_URI` para pruebas.
- ¿Deseas que la página esté enlazada desde la navegación principal (`Navigation.tsx`) o solo accesible por URL inicialmente?

---

## Siguientes pasos sugeridos

- Si eliges MVP manual: implemento `RafflePage.tsx`, añado enlace en `Navigation.tsx`, pruebo `npm run build` y subo los cambios.
- Si eliges automatizar: preparo el esqueleto de la función serverless y documentación para registrar la app en LinkedIn.

---

## Notas finales

- Evitar scraping: recomendamos la vía OAuth+API o el modo manual para respetar TOS y privacidad.
- Este documento está en `docs/docs/feats/raffle-linkedin.md` dentro del repositorio. Copia o adapta el contenido para añadir instrucciones de despliegue o ejemplos concretos de payload cuando configuremos el backend.

38 changes: 24 additions & 14 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import '@primer/react-brand/lib/css/main.css'
import '@primer/react-brand/fonts/fonts.css'
import { ThemeProvider } from '@primer/react-brand';
import { MinimalFooter, Navigation, TimelineSection, HeroSection, CTASection, CardsSection, RiverSection } from './components';
import RafflePage from './pages/RafflePage';

const designTokenOverrides = `
.custom-colors[data-color-mode='dark'] {
Expand All @@ -27,20 +28,29 @@ function App() {
return (
<ThemeProvider colorMode='auto' className="custom-colors">
<style>{designTokenOverrides}</style>
<div style={{
position: 'relative',
width: '100%',
minHeight: '100vh',
backgroundColor: 'var(--brand-color-canvas-default)',
color: 'var(--brand-color-text-default)'
}}>
<Navigation />
<HeroSection />
<CardsSection />
<CTASection />
<TimelineSection />
<RiverSection />
<MinimalFooter socialLinks={["github", "linkedin", "youtube", "x", "meetup"]} />
<div
style={{
position: 'relative',
width: '100%',
minHeight: '100vh',
backgroundColor: 'var(--brand-color-canvas-default)',
color: 'var(--brand-color-text-default)'
}}
>
{/* Render a standalone raffle page when path is /raffle */}
{typeof window !== 'undefined' && window.location && window.location.pathname === '/raffle' ? (
<RafflePage />
) : (
<>
<Navigation />
<HeroSection />
<CardsSection />
<CTASection />
<TimelineSection />
<RiverSection />
<MinimalFooter socialLinks={["github", "linkedin", "youtube", "x", "meetup"]} />
</>
)}
</div>
</ThemeProvider >
)
Expand Down
79 changes: 79 additions & 0 deletions src/components/RaffleSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import React, { useState } from 'react';

const parseParticipants = (text: string) => {
// Allow one participant per line. Trim and filter empties.
return text
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
};

const RaffleSection: React.FC = () => {
const [postUrl, setPostUrl] = useState('');
const [manualList, setManualList] = useState('');
const [winner, setWinner] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const handleDraw = () => {
setError(null);
setWinner(null);
const participants = parseParticipants(manualList);
if (participants.length === 0) {
setError('No hay participantes en la lista manual. Pega una línea por participante.');
return;
}
const idx = Math.floor(Math.random() * participants.length);
setWinner(participants[idx]);
};

return (
<section id="raffle" style={{ padding: '48px 24px', background: 'var(--brand-color-canvas-subtle)' }}>
<div style={{ maxWidth: 900, margin: '0 auto' }}>
<h2>Sorteo de merchandising</h2>
<p>Introduce la URL del post de LinkedIn (opcional) y pega la lista de participantes abajo (una línea por participante). Usa el modo manual por ahora.</p>

<label style={{ display: 'block', marginTop: 12 }} htmlFor="postUrl">URL del post (opcional)</label>
<input
id="postUrl"
type="text"
value={postUrl}
onChange={(e) => setPostUrl(e.target.value)}
placeholder="https://www.linkedin.com/posts/..."
style={{ width: '100%', padding: 8, borderRadius: 6, border: '1px solid #ccc' }}
/>

<label style={{ display: 'block', marginTop: 12 }} htmlFor="manualList">Lista manual de participantes</label>
<textarea
id="manualList"
value={manualList}
onChange={(e) => setManualList(e.target.value)}
placeholder={`Pon un participante por línea\nEjemplo:\nJuan Pérez - https://www.linkedin.com/in/juanperez`}
style={{ width: '100%', minHeight: 180, padding: 8, borderRadius: 6, border: '1px solid #ccc' }}
/>

<div style={{ marginTop: 12, display: 'flex', gap: 8 }}>
<button onClick={handleDraw} style={{ padding: '10px 16px', borderRadius: 6, cursor: 'pointer' }}>Sortear (manual)</button>
<button
onClick={() => setManualList('')}
style={{ padding: '10px 16px', borderRadius: 6, cursor: 'pointer', background: 'transparent', border: '1px solid #ccc' }}
>Limpiar</button>
</div>

{error && <div style={{ marginTop: 12, color: 'var(--brand-color-danger-fg)' }}>{error}</div>}

{winner && (
<div style={{ marginTop: 20, padding: 16, borderRadius: 8, background: 'white', boxShadow: '0 1px 4px rgba(0,0,0,0.08)' }}>
<strong>Ganador:</strong>
<div style={{ marginTop: 8 }}>
<a href={winner.startsWith('http') ? winner : '#'} target="_blank" rel="noreferrer" style={{ color: 'var(--brand-color-text-link)' }}>
{winner}
</a>
</div>
</div>
)}
</div>
</section>
);
};

export default RaffleSection;
1 change: 1 addition & 0 deletions src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ export { default as CTASection } from './CTASection';
export { default as CardsSection } from './CardsSection';
export { default as RiverSection } from './RiverSection';
export { default as TimelineSection } from './TimelineSection';
export { default as RaffleSection } from './RaffleSection';
export { MinimalFooter } from './subcomponents/MinimalFooter';
16 changes: 16 additions & 0 deletions src/pages/RafflePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import React from 'react';
import RaffleSection from '../components/RaffleSection';

const RafflePage: React.FC = () => {
return (
<div style={{ minHeight: '100vh', backgroundColor: 'var(--brand-color-canvas-default)', padding: 24 }}>
<main style={{ maxWidth: 900, margin: '0 auto' }}>
<h1>Sorteo de merchandising</h1>
<p>Esta página está dedicada únicamente al sorteo. Por ahora funciona en modo manual.</p>
<RaffleSection />
</main>
</div>
);
};

export default RafflePage;