Skip to content
Open
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ out/
.DS_Store
*.tsbuildinfo
supabase/.temp
e2e/.results/
e2e/.report/
playwright-report/
62 changes: 62 additions & 0 deletions app/_components/ArticleSearch.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use client"

import { useState, useMemo } from "react"
import { Search } from "lucide-react"
import type { Post } from "@/lib/types"
import { PostCard } from "@/components/PostCard"

interface ArticleSearchProps {
posts: Post[]
}

function normalize(str: string): string {
return str
.normalize("NFD")
.replace(/\p{Diacritic}/gu, "")
.toLowerCase()
}

export function ArticleSearch({ posts }: ArticleSearchProps) {
const [query, setQuery] = useState("")

const filtered = useMemo(() => {
if (query.trim() === "") return posts
const q = normalize(query.trim())
return posts.filter(
(post) => normalize(post.title).includes(q) || normalize(post.excerpt).includes(q)
)
}, [posts, query])

return (
<section>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Últimos artículos</h2>
</div>

<div className="relative mb-6">
<Search
className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<input
type="search"
aria-label="Buscar artículos"
placeholder="Buscar artículos…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-lg border border-border bg-background py-2.5 pl-10 pr-4 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>

{filtered.length === 0 && query.trim() !== "" ? (
<p className="py-12 text-center text-muted-foreground">No se encontraron artículos</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</section>
)
}
59 changes: 59 additions & 0 deletions app/_components/SearchablePostList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
"use client"

import { useMemo, useState } from "react"
import type { Post } from "@/lib/types"
import { PostCard } from "@/components/PostCard"

interface SearchablePostListProps {
posts: Post[]
}

const DIACRITICS_REGEX = /[̀-ͯ]/g

function normalize(str: string): string {
return str.normalize("NFD").replace(DIACRITICS_REGEX, "").toLowerCase()
}

export function SearchablePostList({ posts }: SearchablePostListProps) {
const [query, setQuery] = useState("")

const filtered = useMemo(() => {
const q = normalize(query.trim())
if (!q) return posts
return posts.filter(
(post) =>
normalize(post.title).includes(q) ||
normalize(post.excerpt).includes(q)
)
}, [posts, query])

return (
<div>
<div className="mb-6">
<input
type="search"
aria-label="Buscar artículos"
placeholder="Buscar artículos…"
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full rounded-lg border border-border bg-background px-4 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>

{filtered.length === 0 ? (
<p
role="status"
className="py-12 text-center text-sm text-muted-foreground"
>
No se encontraron artículos
</p>
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
)}
</div>
)
}
2 changes: 1 addition & 1 deletion app/components/AuthNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export function AuthNav() {

async function handleLogout() {
const supabase = createClient()
await supabase.auth.signOut()
await supabase?.auth.signOut()
router.push("/")
router.refresh()
}
Expand Down
2 changes: 1 addition & 1 deletion app/components/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ export function PostCard({ post, className, action }: PostCardProps) {
</p>

<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
<span suppressHydrationWarning>
{new Date(post.publishedAt).toLocaleDateString("es-ES", {
year: "numeric",
month: "long",
Expand Down
5 changes: 5 additions & 0 deletions app/hooks/use-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ export function useUser(): UseUserResult {
useEffect(() => {
const supabase = createClient()

if (!supabase) {
setIsLoading(false)
return
}

supabase.auth.getUser().then(({ data: { user: supabaseUser } }) => {
if (supabaseUser) {
setUser({
Expand Down
8 changes: 4 additions & 4 deletions app/lib/supabase/client.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { createBrowserClient } from "@supabase/ssr"

export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!
)
const url = process.env.NEXT_PUBLIC_SUPABASE_URL
const key = process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY
if (!url || !key) return null
return createBrowserClient(url, key)
}
18 changes: 2 additions & 16 deletions app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import Link from "next/link"
import { getPosts } from "@/lib/api"
import { PostCard } from "@/components/PostCard"
import { ArticleSearch } from "@/_components/ArticleSearch"

export default async function HomePage() {
const posts = await getPosts()
const featured = posts.slice(0, 3)

return (
<div>
Expand All @@ -17,19 +15,7 @@ export default async function HomePage() {
</p>
</section>

<section>
<div className="mb-6 flex items-center justify-between">
<h2 className="text-xl font-semibold text-foreground">Últimos artículos</h2>
<Link href="/blog" className="text-sm text-primary hover:underline">
Ver todos →
</Link>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{featured.map((post) => (
<PostCard key={post.id} post={post} />
))}
</div>
</section>
<ArticleSearch posts={posts} />
</div>
)
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
90 changes: 90 additions & 0 deletions e2e/.report/index.html

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions e2e/.results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "passed",
"failedTests": []
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
71 changes: 71 additions & 0 deletions e2e/article-search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { test, expect } from "@playwright/test"

test.describe("Búsqueda de artículos en tiempo real", () => {
test.beforeEach(async ({ page }) => {
await page.goto("/")
await page.waitForSelector('[aria-label="Buscar artículos"]', { state: "visible" })
})

test("muestra el campo de búsqueda en la página principal", async ({ page }) => {
const input = page.getByRole("searchbox", { name: "Buscar artículos" })
await expect(input).toBeVisible()
await expect(input).toHaveAttribute("placeholder", "Buscar artículos…")
})

test("golden path — filtra artículos al escribir en el campo de búsqueda", async ({ page }) => {
const input = page.getByRole("searchbox", { name: "Buscar artículos" })

const initialCards = page.locator("article")
const initialCount = await initialCards.count()
expect(initialCount).toBeGreaterThan(0)

await input.fill("react")
await expect(input).toHaveValue("react")

const filteredCards = page.locator("article")
await expect(filteredCards.first()).toBeVisible()
const filteredCount = await filteredCards.count()
expect(filteredCount).toBeGreaterThan(0)
expect(filteredCount).toBeLessThanOrEqual(initialCount)
})

test("restaura todos los artículos al borrar el texto", async ({ page }) => {
const input = page.getByRole("searchbox", { name: "Buscar artículos" })

const initialCards = page.locator("article")
const initialCount = await initialCards.count()

await input.fill("react")
await expect(input).toHaveValue("react")

await input.clear()
await expect(input).toHaveValue("")

const restoredCards = page.locator("article")
await expect(restoredCards.first()).toBeVisible()
await expect(restoredCards).toHaveCount(initialCount)
})

test("muestra mensaje cuando no hay resultados", async ({ page }) => {
const input = page.getByRole("searchbox", { name: "Buscar artículos" })

await input.fill("xxxxxxxxxx")
await expect(input).toHaveValue("xxxxxxxxxx")

await expect(page.getByText("No se encontraron artículos")).toBeVisible()
await expect(page.locator("article")).toHaveCount(0)
})

test("el filtrado es case-insensitive y sin distinción de acentos", async ({ page }) => {
const input = page.getByRole("searchbox", { name: "Buscar artículos" })

await input.fill("REACT")
await expect(input).toHaveValue("REACT")
const cards = page.locator("article")
await expect(cards.first()).toBeVisible()

await input.fill("supabase")
await expect(input).toHaveValue("supabase")
await expect(cards.first()).toBeVisible()
})
})
8 changes: 8 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,13 @@ export default defineConfig({
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
env: {
NEXT_PUBLIC_SUPABASE_URL:
process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co',
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY:
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
'placeholder-anon-key',
},
},
})
51 changes: 51 additions & 0 deletions specs/article-search/plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Technical Plan — Búsqueda de Artículos en Tiempo Real

> Derivado de `spec.md` confirmado. Feature slug: `article-search`.

---

## 1. Stack final

- **Frontend:** Next.js 15 App Router + React 19 + TypeScript strict — ya en el proyecto.
- **Filtrado:** cliente-side con `useState` + Array.filter — sin nueva API route ni React Query.
- **Normalización:** función `normalize(str)` con `str.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()`.
- **UI:** Tailwind CSS v4 + primitivas de `app/components/ui/` si aplica.

---

## 2. Componentes

### `app/_components/ArticleSearch.tsx` (nuevo, Client Component)

Responsabilidades:
1. Recibe `posts: Post[]` como prop.
2. Mantiene estado `query: string` con `useState("")`.
3. Filtra los posts comparando `normalize(query)` contra `normalize(post.title)` y `normalize(post.excerpt)`.
4. Renderiza el `<input>` con `aria-label="Buscar artículos"` y placeholder "Buscar artículos…".
5. Renderiza los `<PostCard>` filtrados.
6. Muestra "No se encontraron artículos" si el array filtrado está vacío y `query !== ""`.

### `app/page.tsx` (modificado)

- Sigue siendo async Server Component (obtiene posts en servidor).
- Pasa los posts al componente `<ArticleSearch posts={posts} />`.

---

## 3. Orden de construcción

1. Crear `app/_components/ArticleSearch.tsx`.
2. Modificar `app/page.tsx` para usar `<ArticleSearch>`.
3. Añadir test E2E en `e2e/article-search.spec.ts`.

---

## 4. Definition of done

- [ ] El input aparece en `/` con `aria-label="Buscar artículos"`.
- [ ] Escribir en el input filtra la lista en tiempo real.
- [ ] El filtrado ignora mayúsculas y acentos.
- [ ] Mensaje "No se encontraron artículos" cuando no hay coincidencias.
- [ ] Al borrar, vuelven todos los artículos.
- [ ] Test E2E (golden path) en verde.
- [ ] `npm run lint` y `npm test` en verde.
Loading
Loading