@fullstackdatasolutions/articles 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +324 -0
  2. package/dist/index.cjs +1027 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +232 -0
  5. package/dist/index.d.ts +232 -0
  6. package/dist/index.js +974 -0
  7. package/dist/index.js.map +1 -0
  8. package/dist/server.cjs +732 -0
  9. package/dist/server.cjs.map +1 -0
  10. package/dist/server.d.cts +127 -0
  11. package/dist/server.d.ts +127 -0
  12. package/dist/server.js +684 -0
  13. package/dist/server.js.map +1 -0
  14. package/package.json +82 -0
  15. package/src/ArticleCard.tsx +63 -0
  16. package/src/ArticleCategoryGrid.tsx +73 -0
  17. package/src/ArticleSchemas.tsx +82 -0
  18. package/src/ArticleSearchBar.tsx +50 -0
  19. package/src/ArticlesHero.tsx +33 -0
  20. package/src/ArticlesPage.tsx +167 -0
  21. package/src/CategoryArticlesPage.tsx +84 -0
  22. package/src/CommentForm.tsx +98 -0
  23. package/src/CommentItem.tsx +123 -0
  24. package/src/CommentThread.tsx +40 -0
  25. package/src/CommentsSection.tsx +84 -0
  26. package/src/FeaturedArticle.tsx +63 -0
  27. package/src/LatestArticles.tsx +42 -0
  28. package/src/LatestArticlesSection.tsx +68 -0
  29. package/src/__tests__/ArticleCard.test.tsx +78 -0
  30. package/src/__tests__/ArticleCategoryGrid.test.tsx +98 -0
  31. package/src/__tests__/ArticleSchemas.test.tsx +130 -0
  32. package/src/__tests__/ArticleSearchBar.test.tsx +79 -0
  33. package/src/__tests__/ArticlesHero.test.tsx +38 -0
  34. package/src/__tests__/ArticlesPage.test.tsx +155 -0
  35. package/src/__tests__/CategoryArticlesPage.test.tsx +156 -0
  36. package/src/__tests__/CommentForm.test.tsx +80 -0
  37. package/src/__tests__/CommentItem.test.tsx +149 -0
  38. package/src/__tests__/CommentsSection.test.tsx +118 -0
  39. package/src/__tests__/FeaturedArticle.test.tsx +85 -0
  40. package/src/__tests__/LatestArticles.test.tsx +157 -0
  41. package/src/__tests__/LatestArticlesSection.test.tsx +105 -0
  42. package/src/__tests__/jest-dom.d.ts +1 -0
  43. package/src/__tests__/seoUtils.test.ts +217 -0
  44. package/src/__tests__/useArticles.test.ts +207 -0
  45. package/src/articleTypes.ts +21 -0
  46. package/src/articlesConfig.ts +98 -0
  47. package/src/commentTypes.ts +14 -0
  48. package/src/index.ts +35 -0
  49. package/src/markdown.ts +314 -0
  50. package/src/seoUtils.ts +155 -0
  51. package/src/server-articles.ts +265 -0
  52. package/src/server.ts +25 -0
  53. package/src/useArticles.ts +93 -0
@@ -0,0 +1,84 @@
1
+ 'use client'
2
+
3
+ import Image from 'next/image'
4
+ import Link from 'next/link'
5
+ import { BreadcrumbSchema } from './ArticleSchemas'
6
+ import { LatestArticles } from './LatestArticles'
7
+ import { ArticlesConfig, DEFAULT_PAGE_SIZE } from './articlesConfig'
8
+ import type { Article } from './articleTypes'
9
+
10
+ function getCategoryDescription(
11
+ config: ArticlesConfig,
12
+ slug: string,
13
+ categoryName: string
14
+ ): { short: string; long?: string } {
15
+ const raw = config.categoryDescriptions?.[slug]
16
+ if (!raw) return { short: `Browse all articles in ${categoryName}.` }
17
+ if (typeof raw === 'string') return { short: raw }
18
+ return raw
19
+ }
20
+
21
+ type CategoryArticlesPageProps = Readonly<{
22
+ category: string
23
+ articles: Article[]
24
+ config: ArticlesConfig
25
+ }>
26
+
27
+ export function CategoryArticlesPage({ category, articles, config }: CategoryArticlesPageProps) {
28
+ if (articles.length === 0) return null
29
+
30
+ const categoryName = articles[0].category
31
+ const heroImage = articles[0].featuredImage
32
+ const description = getCategoryDescription(config, category, categoryName)
33
+ const pageSize = config.pageSize ?? DEFAULT_PAGE_SIZE
34
+ const siteUrl = config.siteUrl.replace(/\/$/, '')
35
+
36
+ return (
37
+ <div>
38
+ <BreadcrumbSchema
39
+ items={[
40
+ { name: 'Home', url: siteUrl },
41
+ { name: 'Articles', url: `${siteUrl}/articles` },
42
+ { name: categoryName, url: `${siteUrl}/articles/category/${category}` },
43
+ ]}
44
+ />
45
+ {/* Hero */}
46
+ <section className="relative h-72 md:h-96 flex items-center justify-center overflow-hidden">
47
+ <Image
48
+ src={heroImage}
49
+ alt={categoryName}
50
+ fill
51
+ sizes="100vw"
52
+ className="object-cover"
53
+ priority
54
+ />
55
+ <div className="absolute inset-0 bg-black/60" />
56
+ <div className="relative z-10 text-center text-white px-4">
57
+ <p className="text-sm font-semibold uppercase tracking-widest mb-3 opacity-80">
58
+ Category
59
+ </p>
60
+ <h1 className="text-4xl md:text-5xl font-bold mb-3">{categoryName}</h1>
61
+ {description.long && (
62
+ <p className="text-lg opacity-90 max-w-2xl mx-auto mt-2">{description.long}</p>
63
+ )}
64
+ <p className="text-lg opacity-70 mt-2">
65
+ {articles.length} article{articles.length === 1 ? '' : 's'}
66
+ </p>
67
+ </div>
68
+ </section>
69
+
70
+ {/* Articles */}
71
+ <section className="py-16 bg-muted/50 flex-1">
72
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
73
+ <div className="mb-8 flex items-center justify-between">
74
+ <Link href="/articles" className="text-sm text-primary hover:underline">
75
+ ← All Articles
76
+ </Link>
77
+ <p className="text-sm text-muted-foreground">{description.short}</p>
78
+ </div>
79
+ <LatestArticles articles={articles} pageSize={pageSize} />
80
+ </div>
81
+ </section>
82
+ </div>
83
+ )
84
+ }
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+
5
+ const MAX_LENGTH = 2000
6
+ const WARN_THRESHOLD = 1800
7
+
8
+ function submitLabel(submitting: boolean, parentId?: string): string {
9
+ if (submitting) return 'Posting…'
10
+ return parentId ? 'Reply' : 'Post comment'
11
+ }
12
+
13
+ interface CommentFormProps {
14
+ readonly articleSlug: string
15
+ readonly parentId?: string
16
+ readonly onSuccess: () => void
17
+ readonly onCancel?: () => void
18
+ }
19
+
20
+ export function CommentForm({ articleSlug, parentId, onSuccess, onCancel }: CommentFormProps) {
21
+ const [body, setBody] = useState('')
22
+ const [submitting, setSubmitting] = useState(false)
23
+ const [error, setError] = useState<string | null>(null)
24
+
25
+ const remaining = MAX_LENGTH - body.length
26
+
27
+ async function handleSubmit(e: React.FormEvent) {
28
+ e.preventDefault()
29
+ if (!body.trim() || submitting) return
30
+
31
+ setSubmitting(true)
32
+ setError(null)
33
+
34
+ try {
35
+ const res = await fetch(`/api/articles/${articleSlug}/comments`, {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({ body: body.trim(), parentId: parentId ?? null }),
39
+ })
40
+
41
+ if (!res.ok) {
42
+ const data = (await res.json()) as { error?: string }
43
+ setError(data.error ?? 'Failed to post comment')
44
+ return
45
+ }
46
+
47
+ setBody('')
48
+ onSuccess()
49
+ } catch {
50
+ setError('Network error — please try again')
51
+ } finally {
52
+ setSubmitting(false)
53
+ }
54
+ }
55
+
56
+ return (
57
+ <form onSubmit={handleSubmit} className="space-y-2">
58
+ <textarea
59
+ value={body}
60
+ onChange={(e) => setBody(e.target.value)}
61
+ maxLength={MAX_LENGTH}
62
+ rows={parentId ? 3 : 4}
63
+ placeholder={parentId ? 'Write a reply…' : 'Join the discussion…'}
64
+ disabled={submitting}
65
+ className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 resize-none"
66
+ aria-label={parentId ? 'Reply text' : 'Comment text'}
67
+ />
68
+
69
+ {remaining <= MAX_LENGTH - WARN_THRESHOLD && (
70
+ <p className={`text-xs ${remaining < 0 ? 'text-destructive' : 'text-muted-foreground'}`}>
71
+ {remaining} characters remaining
72
+ </p>
73
+ )}
74
+
75
+ {error && <p className="text-xs text-destructive">{error}</p>}
76
+
77
+ <div className="flex gap-2">
78
+ <button
79
+ type="submit"
80
+ disabled={submitting || body.trim().length === 0 || body.length > MAX_LENGTH}
81
+ className="rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
82
+ >
83
+ {submitLabel(submitting, parentId)}
84
+ </button>
85
+
86
+ {onCancel && (
87
+ <button
88
+ type="button"
89
+ onClick={onCancel}
90
+ className="rounded-md px-4 py-1.5 text-sm font-medium text-muted-foreground hover:text-foreground"
91
+ >
92
+ Cancel
93
+ </button>
94
+ )}
95
+ </div>
96
+ </form>
97
+ )
98
+ }
@@ -0,0 +1,123 @@
1
+ 'use client'
2
+
3
+ import { useState } from 'react'
4
+ import { CommentForm } from './CommentForm'
5
+ import type { ArticleComment, ArticleCommentWithReplies } from './commentTypes'
6
+
7
+ function relativeTime(iso: string): string {
8
+ const diff = Date.now() - new Date(iso).getTime()
9
+ const minutes = Math.floor(diff / 60_000)
10
+ if (minutes < 1) return 'just now'
11
+ if (minutes < 60) return `${minutes}m ago`
12
+ const hours = Math.floor(minutes / 60)
13
+ if (hours < 24) return `${hours}h ago`
14
+ const days = Math.floor(hours / 24)
15
+ if (days < 30) return `${days}d ago`
16
+ const months = Math.floor(days / 30)
17
+ if (months < 12) return `${months}mo ago`
18
+ return `${Math.floor(months / 12)}y ago`
19
+ }
20
+
21
+ interface CommentItemProps {
22
+ readonly comment: ArticleCommentWithReplies | ArticleComment
23
+ readonly articleSlug: string
24
+ readonly currentUserId?: string
25
+ readonly isReply?: boolean
26
+ readonly onRefresh: () => void
27
+ }
28
+
29
+ export function CommentItem({
30
+ comment,
31
+ articleSlug,
32
+ currentUserId,
33
+ isReply = false,
34
+ onRefresh,
35
+ }: CommentItemProps) {
36
+ const [showReplyForm, setShowReplyForm] = useState(false)
37
+ const [deleting, setDeleting] = useState(false)
38
+
39
+ const replies = 'replies' in comment ? comment.replies : []
40
+ const canReply = !isReply && !!currentUserId && !comment.isDeleted
41
+ const canDelete = !!currentUserId && currentUserId === comment.authorId && !comment.isDeleted
42
+
43
+ async function handleDelete() {
44
+ if (deleting) return
45
+ setDeleting(true)
46
+ try {
47
+ await fetch(`/api/articles/${articleSlug}/comments/${comment.id}`, { method: 'DELETE' })
48
+ onRefresh()
49
+ } finally {
50
+ setDeleting(false)
51
+ }
52
+ }
53
+
54
+ return (
55
+ <div className={`${isReply ? 'ml-8 border-l-2 border-border pl-4' : ''}`}>
56
+ <div className="rounded-lg bg-muted/40 p-3 space-y-1">
57
+ {comment.isDeleted ? (
58
+ <p className="text-sm italic text-muted-foreground">Comment removed</p>
59
+ ) : (
60
+ <>
61
+ <div className="flex items-center justify-between gap-2">
62
+ <span className="text-sm font-medium text-foreground">{comment.authorName}</span>
63
+ <span className="text-xs text-muted-foreground">
64
+ {relativeTime(comment.createdAt)}
65
+ </span>
66
+ </div>
67
+ <p className="text-sm text-foreground whitespace-pre-wrap break-words">
68
+ {comment.body}
69
+ </p>
70
+ <div className="flex gap-3 pt-1">
71
+ {canReply && (
72
+ <button
73
+ onClick={() => setShowReplyForm((v) => !v)}
74
+ className="text-xs text-muted-foreground hover:text-foreground"
75
+ >
76
+ {showReplyForm ? 'Cancel' : 'Reply'}
77
+ </button>
78
+ )}
79
+ {canDelete && (
80
+ <button
81
+ onClick={handleDelete}
82
+ disabled={deleting}
83
+ className="text-xs text-muted-foreground hover:text-destructive disabled:opacity-50"
84
+ >
85
+ {deleting ? 'Deleting…' : 'Delete'}
86
+ </button>
87
+ )}
88
+ </div>
89
+ </>
90
+ )}
91
+ </div>
92
+
93
+ {showReplyForm && (
94
+ <div className="mt-2 ml-2">
95
+ <CommentForm
96
+ articleSlug={articleSlug}
97
+ parentId={comment.id}
98
+ onSuccess={() => {
99
+ setShowReplyForm(false)
100
+ onRefresh()
101
+ }}
102
+ onCancel={() => setShowReplyForm(false)}
103
+ />
104
+ </div>
105
+ )}
106
+
107
+ {replies.length > 0 && (
108
+ <div className="mt-2 space-y-2">
109
+ {replies.map((reply) => (
110
+ <CommentItem
111
+ key={reply.id}
112
+ comment={{ ...reply, replies: [] }}
113
+ articleSlug={articleSlug}
114
+ currentUserId={currentUserId}
115
+ isReply
116
+ onRefresh={onRefresh}
117
+ />
118
+ ))}
119
+ </div>
120
+ )}
121
+ </div>
122
+ )
123
+ }
@@ -0,0 +1,40 @@
1
+ 'use client'
2
+
3
+ import { CommentItem } from './CommentItem'
4
+ import type { ArticleCommentWithReplies } from './commentTypes'
5
+
6
+ interface CommentThreadProps {
7
+ readonly comments: ArticleCommentWithReplies[]
8
+ readonly articleSlug: string
9
+ readonly currentUserId?: string
10
+ readonly onRefresh: () => void
11
+ }
12
+
13
+ export function CommentThread({
14
+ comments,
15
+ articleSlug,
16
+ currentUserId,
17
+ onRefresh,
18
+ }: CommentThreadProps) {
19
+ if (comments.length === 0) {
20
+ return (
21
+ <p className="text-sm text-muted-foreground text-center py-6">
22
+ No comments yet. Be the first to share your thoughts.
23
+ </p>
24
+ )
25
+ }
26
+
27
+ return (
28
+ <div className="space-y-4">
29
+ {comments.map((comment) => (
30
+ <CommentItem
31
+ key={comment.id}
32
+ comment={comment}
33
+ articleSlug={articleSlug}
34
+ currentUserId={currentUserId}
35
+ onRefresh={onRefresh}
36
+ />
37
+ ))}
38
+ </div>
39
+ )
40
+ }
@@ -0,0 +1,84 @@
1
+ 'use client'
2
+
3
+ import { useSession, signIn } from 'next-auth/react'
4
+ import { useCallback, useEffect, useState } from 'react'
5
+ import { CommentForm } from './CommentForm'
6
+ import { CommentThread } from './CommentThread'
7
+ import type { CommentsConfig } from './articlesConfig'
8
+ import type { ArticleCommentWithReplies } from './commentTypes'
9
+
10
+ interface CommentsSectionProps {
11
+ readonly articleSlug: string
12
+ readonly config: CommentsConfig
13
+ }
14
+
15
+ export function CommentsSection({ articleSlug, config }: CommentsSectionProps) {
16
+ const { data: session, status } = useSession()
17
+ const [comments, setComments] = useState<ArticleCommentWithReplies[]>([])
18
+ const [loading, setLoading] = useState(true)
19
+ const [fetchError, setFetchError] = useState<string | null>(null)
20
+
21
+ const perArticleEnabled = config.perArticleOverride?.[articleSlug] ?? true
22
+ const enabled = config.enabled && perArticleEnabled
23
+
24
+ const currentUserId = session?.user?.email ?? session?.user?.name ?? undefined
25
+
26
+ const fetchComments = useCallback(async () => {
27
+ setLoading(true)
28
+ setFetchError(null)
29
+ try {
30
+ const res = await fetch(`/api/articles/${articleSlug}/comments`)
31
+ if (!res.ok) throw new Error('Failed to load comments')
32
+ const data = (await res.json()) as { comments: ArticleCommentWithReplies[] }
33
+ setComments(data.comments)
34
+ } catch {
35
+ setFetchError('Could not load comments. Please try again.')
36
+ } finally {
37
+ setLoading(false)
38
+ }
39
+ }, [articleSlug])
40
+
41
+ useEffect(() => {
42
+ if (enabled) {
43
+ fetchComments().catch(() => undefined)
44
+ }
45
+ }, [fetchComments, enabled])
46
+
47
+ if (!enabled) return null
48
+
49
+ const count = comments.length
50
+ const label = count === 1 ? '1 comment' : `${count} comments`
51
+
52
+ return (
53
+ <section aria-label="Comments" className="mt-12 pt-8 border-t border-border space-y-6">
54
+ <h2 className="text-xl font-semibold text-foreground">{loading ? 'Comments' : label}</h2>
55
+
56
+ {status === 'authenticated' ? (
57
+ <CommentForm articleSlug={articleSlug} onSuccess={fetchComments} />
58
+ ) : (
59
+ <p className="text-sm text-muted-foreground">
60
+ <button
61
+ onClick={() => signIn()}
62
+ className="text-primary underline hover:no-underline focus:outline-none"
63
+ >
64
+ Sign in
65
+ </button>{' '}
66
+ to join the discussion.
67
+ </p>
68
+ )}
69
+
70
+ {fetchError && <p className="text-sm text-destructive">{fetchError}</p>}
71
+
72
+ {loading ? (
73
+ <p className="text-sm text-muted-foreground">Loading comments…</p>
74
+ ) : (
75
+ <CommentThread
76
+ comments={comments}
77
+ articleSlug={articleSlug}
78
+ currentUserId={currentUserId}
79
+ onRefresh={fetchComments}
80
+ />
81
+ )}
82
+ </section>
83
+ )
84
+ }
@@ -0,0 +1,63 @@
1
+ import Link from 'next/link'
2
+ import Image from 'next/image'
3
+ import { ArrowRight, Calendar, Clock, User } from 'lucide-react'
4
+ import type { Article } from './articleTypes'
5
+
6
+ type FeaturedArticleProps = Readonly<{
7
+ article: Article
8
+ }>
9
+
10
+ export function FeaturedArticle({ article }: FeaturedArticleProps) {
11
+ return (
12
+ <section className="py-16 bg-background">
13
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
14
+ <div className="grid lg:grid-cols-2 gap-12 items-center">
15
+ <div>
16
+ <span className="inline-block bg-primary/10 text-primary text-sm font-medium px-3 py-1 rounded-full mb-4">
17
+ FEATURED ARTICLE
18
+ </span>
19
+ <h2 className="text-3xl font-bold text-foreground mb-4">{article.title}</h2>
20
+ <p className="text-lg text-muted-foreground mb-6">{article.excerpt}</p>
21
+ <div className="flex items-center gap-6 text-sm text-muted-foreground mb-6">
22
+ {article.date && (
23
+ <div className="flex items-center gap-2">
24
+ <Calendar className="h-4 w-4" />
25
+ <span>{article.date}</span>
26
+ </div>
27
+ )}
28
+ <div className="flex items-center gap-2">
29
+ <Clock className="h-4 w-4" />
30
+ <span>{article.readTime}</span>
31
+ </div>
32
+ <div className="flex items-center gap-2">
33
+ <User className="h-4 w-4" />
34
+ <span>{article.author}</span>
35
+ </div>
36
+ </div>
37
+ <Link
38
+ href={`/articles/${article.slug}`}
39
+ className="inline-flex items-center justify-center px-4 py-2 bg-primary hover:bg-primary/90 text-primary-foreground rounded-md font-medium text-sm transition-colors"
40
+ >
41
+ Read Full Article
42
+ <ArrowRight className="ml-2 h-4 w-4" />
43
+ </Link>
44
+ </div>
45
+ <div className="bg-card rounded-lg p-8">
46
+ <Image
47
+ src={article.featuredImage}
48
+ alt={article.title}
49
+ width={800}
50
+ height={450}
51
+ className="w-full h-64 object-cover rounded-lg mb-4"
52
+ />
53
+ <div className="flex gap-2">
54
+ <span className="bg-primary/10 text-primary text-xs font-medium px-2 py-1 rounded">
55
+ {article.category}
56
+ </span>
57
+ </div>
58
+ </div>
59
+ </div>
60
+ </div>
61
+ </section>
62
+ )
63
+ }
@@ -0,0 +1,42 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import type { Article } from './articleTypes'
5
+ import { ArticleCard } from './ArticleCard'
6
+
7
+ interface LatestArticlesProps {
8
+ readonly articles: Article[]
9
+ readonly pageSize: number
10
+ }
11
+
12
+ export function LatestArticles({ articles, pageSize }: Readonly<LatestArticlesProps>) {
13
+ const [visibleCount, setVisibleCount] = useState(pageSize)
14
+
15
+ useEffect(() => {
16
+ setVisibleCount(pageSize)
17
+ }, [articles, pageSize])
18
+
19
+ const visible = articles.slice(0, visibleCount)
20
+ const hasMore = visibleCount < articles.length
21
+
22
+ return (
23
+ <div>
24
+ <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
25
+ {visible.map((article) => (
26
+ <ArticleCard key={article.slug} article={article} />
27
+ ))}
28
+ </div>
29
+ {hasMore && (
30
+ <div className="mt-10 text-center">
31
+ <button
32
+ type="button"
33
+ onClick={() => setVisibleCount((c) => c + pageSize)}
34
+ className="inline-flex items-center justify-center px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
35
+ >
36
+ Load more articles
37
+ </button>
38
+ </div>
39
+ )}
40
+ </div>
41
+ )
42
+ }
@@ -0,0 +1,68 @@
1
+ 'use client'
2
+
3
+ import { Search } from 'lucide-react'
4
+ import type { Article } from './articleTypes'
5
+ import { LatestArticles } from './LatestArticles'
6
+
7
+ interface LatestArticlesSectionProps {
8
+ readonly articles: Article[]
9
+ readonly searchQuery: string
10
+ readonly onClearSearch: () => void
11
+ readonly pageSize?: number
12
+ }
13
+
14
+ function DescriptionText({
15
+ searchQuery,
16
+ count,
17
+ }: {
18
+ readonly searchQuery: string
19
+ readonly count: number
20
+ }) {
21
+ const pluralSuffix = count === 1 ? '' : 's'
22
+ const text = searchQuery
23
+ ? `Found ${count} article${pluralSuffix} matching your search.`
24
+ : 'Stay informed with our latest insights on voter engagement, campaign strategies, and civic technology.'
25
+ return <p className="text-lg text-muted-foreground">{text}</p>
26
+ }
27
+
28
+ export function LatestArticlesSection({
29
+ articles,
30
+ searchQuery,
31
+ onClearSearch,
32
+ pageSize = 6,
33
+ }: Readonly<LatestArticlesSectionProps>) {
34
+ return (
35
+ <section id="latest" className="py-16 bg-muted/50">
36
+ <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
37
+ <div className="text-center mb-12">
38
+ <h2 className="text-3xl font-bold text-foreground mb-4">
39
+ {searchQuery ? 'Search Results' : 'Latest Articles'}
40
+ </h2>
41
+ <DescriptionText searchQuery={searchQuery} count={articles.length} />
42
+ </div>
43
+
44
+ {articles.length === 0 && searchQuery ? (
45
+ <div className="text-center py-12">
46
+ <div className="max-w-md mx-auto">
47
+ <Search className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
48
+ <h3 className="text-lg font-semibold text-foreground mb-2">No articles found</h3>
49
+ <p className="text-muted-foreground mb-4">
50
+ We couldn&apos;t find any articles matching &quot;{searchQuery}&quot;. Try adjusting
51
+ your search terms.
52
+ </p>
53
+ <button
54
+ type="button"
55
+ onClick={onClearSearch}
56
+ className="inline-flex items-center justify-center px-4 py-2 border border-input bg-background hover:bg-accent rounded-md font-medium text-sm transition-colors"
57
+ >
58
+ Clear search
59
+ </button>
60
+ </div>
61
+ </div>
62
+ ) : (
63
+ <LatestArticles articles={articles} pageSize={pageSize} key={searchQuery} />
64
+ )}
65
+ </div>
66
+ </section>
67
+ )
68
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react'
5
+ import { render, screen } from '@testing-library/react'
6
+ import type { Article } from '../articleTypes'
7
+ import { ArticleCard } from '../ArticleCard'
8
+
9
+ jest.mock('next/link', () => ({
10
+ __esModule: true,
11
+ default: ({ href, children }: { href: string; children: React.ReactNode }) => (
12
+ <a href={href}>{children}</a>
13
+ ),
14
+ }))
15
+
16
+ jest.mock('next/image', () => ({
17
+ __esModule: true,
18
+ default: (props: React.ComponentProps<'img'>) => <img alt="test" {...props} />,
19
+ }))
20
+
21
+ const baseArticle: Article = {
22
+ slug: 'test-article',
23
+ title: 'Test Article Title',
24
+ excerpt: 'A short excerpt about the article.',
25
+ author: 'Jane Doe',
26
+ category: 'Campaigns',
27
+ categories: ['Campaigns'],
28
+ readTime: '5 min read',
29
+ featuredImage: '/articles/test-article/image.jpg',
30
+ tags: ['campaigns'],
31
+ }
32
+
33
+ describe('ArticleCard', () => {
34
+ it('renders the article title linked to the article page', () => {
35
+ render(<ArticleCard article={baseArticle} />)
36
+ const link = screen.getByRole('link', { name: 'Test Article Title' })
37
+ expect(link).toHaveAttribute('href', '/articles/test-article')
38
+ })
39
+
40
+ it('renders the excerpt', () => {
41
+ render(<ArticleCard article={baseArticle} />)
42
+ expect(screen.getByText('A short excerpt about the article.')).toBeInTheDocument()
43
+ })
44
+
45
+ it('renders the category badge', () => {
46
+ render(<ArticleCard article={baseArticle} />)
47
+ expect(screen.getByText('Campaigns')).toBeInTheDocument()
48
+ })
49
+
50
+ it('renders the read time', () => {
51
+ render(<ArticleCard article={baseArticle} />)
52
+ expect(screen.getByText('5 min read')).toBeInTheDocument()
53
+ })
54
+
55
+ it('renders the featured image with correct alt text', () => {
56
+ render(<ArticleCard article={baseArticle} />)
57
+ const img = screen.getByAltText('Test Article Title')
58
+ expect(img).toBeInTheDocument()
59
+ })
60
+
61
+ it('renders the date when article.date is set', () => {
62
+ render(<ArticleCard article={{ ...baseArticle, date: '2025-06-15' }} />)
63
+ expect(screen.getByText('2025-06-15')).toBeInTheDocument()
64
+ })
65
+
66
+ it('does not render a date when article.date is absent', () => {
67
+ render(<ArticleCard article={baseArticle} />)
68
+ expect(screen.queryByText(/2025/)).not.toBeInTheDocument()
69
+ })
70
+
71
+ it('arrow link also points to the article page', () => {
72
+ render(<ArticleCard article={baseArticle} />)
73
+ const links = screen
74
+ .getAllByRole('link')
75
+ .filter((l) => l.getAttribute('href') === '/articles/test-article')
76
+ expect(links.length).toBeGreaterThanOrEqual(2)
77
+ })
78
+ })