diff --git a/__tests__/index.test.js b/__tests__/index.test.js index d5b82ee..1c45661 100644 --- a/__tests__/index.test.js +++ b/__tests__/index.test.js @@ -1,6 +1,6 @@ import '@testing-library/jest-dom'; import { render, screen } from '@testing-library/react'; -import TopicsPage from '../src/app/page'; +import Page from '../src/app/page'; import RootLayout from '../src/app/layout'; import { fetchInterviews } from '../src/data/fetchInterviewData'; diff --git a/prefix.ts b/prefix.ts new file mode 100644 index 0000000..997d890 --- /dev/null +++ b/prefix.ts @@ -0,0 +1,3 @@ +const prefix = process.env.NEXT_PUBLIC_BASE_PATH || ''; + +export { prefix }; diff --git a/src/app/[topic]/page.tsx b/src/app/[path]/[topic]/page.tsx similarity index 59% rename from src/app/[topic]/page.tsx rename to src/app/[path]/[topic]/page.tsx index 0a71aee..aa5b935 100644 --- a/src/app/[topic]/page.tsx +++ b/src/app/[path]/[topic]/page.tsx @@ -1,20 +1,22 @@ import { GetStaticPathsResult } from 'next'; -import { legalTopics, Topic } from '../../config/topics.config'; +import { legalTopics, Topic } from '../../../config/topics.config'; interface PageProps { params: { topic: string; + path: string; }; } const Page = ({ params }: PageProps) => { - const { topic } = params; + const { topic, path } = params; return
Topic: {topic}
; }; export default Page; -export async function generateStaticParams() { +export async function generateStaticParams({ params }) { + const { path } = params; return legalTopics.map((topic) => ({ topic: topic.name.toLowerCase(), })); diff --git a/src/app/[path]/layout.tsx b/src/app/[path]/layout.tsx new file mode 100644 index 0000000..8271883 --- /dev/null +++ b/src/app/[path]/layout.tsx @@ -0,0 +1,13 @@ +import { pathToServerConfig } from '../../config/formSources.config'; + +// This is only necessary because there is a bug within next that doesnt allow static params to be passed from page to page properly. This layout only exists to assist in passing said props + +export async function generateStaticParams() { + return Object.keys(pathToServerConfig).map((key) => ({ + path: key.toLowerCase(), + })); +} + +export default function Layout({ children }) { + return <>{children}; +} diff --git a/src/app/[path]/page.tsx b/src/app/[path]/page.tsx new file mode 100644 index 0000000..f206c44 --- /dev/null +++ b/src/app/[path]/page.tsx @@ -0,0 +1,29 @@ +import HowItWorksSection from '../components/HowItWorksSection'; +import TopicsSection from '../components/TopicsSection'; +import HeroSection from '../components/HeroSection'; +import { fetchInterviews } from '../../data/fetchInterviewData'; + +interface PageProps { + params: { + path: string; + }; +} + +const Page = async ({ params }: PageProps) => { + const { path } = params; + const { interviewsByTopic, isError } = await fetchInterviews(path); + + return ( +
+ + + +
+ ); +}; + +export default Page; diff --git a/src/app/components/Footer.tsx b/src/app/components/Footer.tsx index 809cfae..e1478b6 100644 --- a/src/app/components/Footer.tsx +++ b/src/app/components/Footer.tsx @@ -1,5 +1,6 @@ import Link from 'next/link'; import Image from 'next/image'; +import { prefix } from '../../../prefix'; export default function Footer() { return ( @@ -9,7 +10,7 @@ export default function Footer() {
Suffolk University Law School
- Logo diff --git a/src/app/components/TopicCard.tsx b/src/app/components/TopicCard.tsx index 7e49165..0a3e3c6 100644 --- a/src/app/components/TopicCard.tsx +++ b/src/app/components/TopicCard.tsx @@ -12,6 +12,7 @@ interface TopicCardProps { interviews: any[]; index: number; serverUrl: string; + path: string; } interface IconProps { @@ -28,10 +29,15 @@ const FontAwesomeIcon = ({ return ; }; -const TopicCard = ({ topic, interviews, index, serverUrl }: TopicCardProps) => { +const TopicCard = ({ + topic, + interviews, + index, + serverUrl, + path, +}: TopicCardProps) => { const [isExpanded, setIsExpanded] = useState(false); const visibilityClass = index > 8 ? 'hidden' : ''; - const displayInterviews = isExpanded ? interviews.slice(0, Math.min(10, interviews.length)) : interviews.slice(0, 3); @@ -54,13 +60,10 @@ const TopicCard = ({ topic, interviews, index, serverUrl }: TopicCardProps) => { key={topic.codes[0]} > -
e.preventDefault()} - > +
{ className="tag-container" style={{ maxHeight: isExpanded ? '800px' : '200px' }} > - {displayInterviews.map((interview, index) => ( - { - e.preventDefault(); - handleNavigation(serverUrl + interview.link); - }} - > - {interview.metadata.title} - - ))} + {displayInterviews.map((interview, index) => { + if (interview.metadata && interview.metadata.title) { + return ( + { + e.preventDefault(); + handleNavigation(interview.serverUrl + interview.link); + }} + > + {interview.metadata.title} + + ); + } + return null; + })}
{interviews.length > 3 && (
diff --git a/src/app/components/TopicsSection.tsx b/src/app/components/TopicsSection.tsx new file mode 100644 index 0000000..522d83f --- /dev/null +++ b/src/app/components/TopicsSection.tsx @@ -0,0 +1,44 @@ +import { legalTopics } from '../../config/topics.config'; +import { formSources } from '../../config/formSources.config'; +import TopicCard from './TopicCard'; +import ShowAllToggle from './ShowAllToggle'; + +const TopicsSection = async ({ path, interviews, isError }) => { + if (isError) { + return
Error fetching data.
; + } + + const server = + formSources.docassembleServers.find((server) => server.path === path) || + formSources.docassembleServers[0]; + const serverUrl = server.url; + + const filteredTopics = legalTopics + .sort((a, b) => b.priority - a.priority) + .filter( + (topic) => topic.always_visible || interviews[topic.name].length > 0 + ); + + return ( +
+
+

Browse court forms by category

+
+ {filteredTopics.map((topic, index) => ( + + ))} +
+ {filteredTopics.length > 9 && } +
+
+ ); +}; + +export default TopicsSection; diff --git a/src/app/globals.css b/src/app/globals.css index eebb154..1668a8b 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -192,14 +192,6 @@ h5, font-weight: 600; } -h1, -h2, -h3, -h4, -h5 { - margin-top: 2em; -} - body { font-family: 'Inter'; } diff --git a/src/app/page.tsx b/src/app/page.tsx index 9ac97ae..a8ab847 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,52 +1,20 @@ -import HeroSection from './components/HeroSection'; import HowItWorksSection from './components/HowItWorksSection'; +import TopicsSection from './components/TopicsSection'; +import HeroSection from './components/HeroSection'; import { fetchInterviews } from '../data/fetchInterviewData'; -import TopicCard from './components/TopicCard'; -import { legalTopics } from '../config/topics.config'; -import ShowAllToggle from './components/ShowAllToggle'; -import { formSources } from '../config/formSources.config'; - -export default async function TopicsPage() { - const interviewsResult = await fetchInterviews(); - const { interviewsByTopic } = interviewsResult; - - // Modify this to account for various jurisdictions - const serverKey = 'suffolkListLab'; - const server = formSources.docassembleServers.find( - (server) => server.key === serverKey - ); - const url = server ? server.url : 'https://apps.suffolklitlab.org'; - const filteredTopics = legalTopics - .sort((a, b) => b.priority - a.priority) - .filter( - (topic, index) => - topic.always_visible || - (interviewsByTopic[topic.name] && - interviewsByTopic[topic.name].length > 0) - ); +export default async function Page() { + const { interviewsByTopic, isError } = await fetchInterviews('ma'); return (
-
-
-

Browse court forms by category

-
- {filteredTopics.map((topic, index) => ( - - ))} -
- {filteredTopics.length > 9 && } -
-
+
); } diff --git a/src/config/formSources.config.js b/src/config/formSources.config.js index 131f802..5e68617 100644 --- a/src/config/formSources.config.js +++ b/src/config/formSources.config.js @@ -1,3 +1,14 @@ +export const pathToServerConfig = { + ma: { + path: 'ma', + servers: ['Suffolk LIT Lab', 'Greater Boston Legal Services'], + }, + gb: { + path: 'gb', + servers: ['Greater Boston Legal Services'], + }, +}; + export const formSources = { docassembleServers: [ { diff --git a/src/data/fetchInterviewData.ts b/src/data/fetchInterviewData.ts index 2232d49..3b3a4ba 100644 --- a/src/data/fetchInterviewData.ts +++ b/src/data/fetchInterviewData.ts @@ -1,56 +1,73 @@ -import { formSources } from '../config/formSources.config'; +import { formSources, pathToServerConfig } from '../config/formSources.config'; import { legalTopics } from '../config/topics.config'; import { findClosestTopic } from './helpers'; -export const fetchInterviews = async () => { - const serverUrl = formSources.docassembleServers[0].url; - const url = new URL(`${serverUrl}/list`); - url.search = 'json=1'; - - try { - const response = await fetch(url.toString()); - const data = await response.json(); - const interviewsByTopic = {}; - const titlesInTopics = {}; - - legalTopics.forEach((topic) => { - interviewsByTopic[topic.name] = []; - titlesInTopics[topic.name] = new Set(); - }); - - if (data && data.interviews) { - data.interviews.forEach((interview) => { - const uniqueTopics = new Set(); - - // match topics to config by metadata.LIST_Topic, and tags values - (interview.metadata.LIST_topics || []) - .concat(interview.tags || []) - .forEach((code) => { - const topic = findClosestTopic(code, legalTopics); - if (topic && !uniqueTopics.has(topic.name)) { - uniqueTopics.add(topic.name); - // check if the title already exists in the topic to avoid duplicated titles - if (!titlesInTopics[topic.name].has(interview.title)) { - interviewsByTopic[topic.name].push(interview); - titlesInTopics[topic.name].add(interview.title); - } - } - }); - - // add to other if no matching topic in config - if (uniqueTopics.size === 0) { - interviewsByTopic['Other'] = interviewsByTopic['Other'] || []; - if (!titlesInTopics['Other'].has(interview.title)) { - interviewsByTopic['Other'].push(interview); - titlesInTopics['Other'].add(interview.title); +export const fetchInterviews = async (path) => { + const config = pathToServerConfig[path]; + const serverNames = config + ? config.servers + : [formSources.docassembleServers[0].name]; + const servers = formSources.docassembleServers.filter((server) => + serverNames.includes(server.name) + ); + + let allInterviews = []; + for (const server of servers) { + const url = new URL(`${server.url}/list`); + url.search = 'json=1'; + + try { + const response = await fetch(url.toString()); + const data = await response.json(); + if (data && data.interviews) { + const taggedInterviews = data.interviews.map((interview) => ({ + ...interview, + serverUrl: server.url, + })); + allInterviews = allInterviews.concat(taggedInterviews); + } + } catch (error) { + console.error( + 'Failed to fetch interviews from server:', + server.name, + error + ); + } + } + + const interviewsByTopic = {}; + const titlesInTopics = {}; + legalTopics.forEach((topic) => { + interviewsByTopic[topic.name] = []; + titlesInTopics[topic.name] = new Set(); + }); + + allInterviews.forEach((interview) => { + const uniqueTopics = new Set(); + + // Match topics by metadata.LIST_topics and tags + (interview.metadata.LIST_topics || []) + .concat(interview.tags || []) + .forEach((code) => { + const topic = findClosestTopic(code, legalTopics); + if (topic && !uniqueTopics.has(topic.name)) { + uniqueTopics.add(topic.name); + if (!titlesInTopics[topic.name].has(interview.title)) { + interviewsByTopic[topic.name].push(interview); + titlesInTopics[topic.name].add(interview.title); } } }); + + if ( + uniqueTopics.size === 0 && + !titlesInTopics['Other'].has(interview.title) + ) { + interviewsByTopic['Other'] = interviewsByTopic['Other'] || []; + interviewsByTopic['Other'].push(interview); + titlesInTopics['Other'].add(interview.title); } + }); - return { interviewsByTopic, isError: false }; - } catch (error) { - console.error('Failed to fetch interviews:', error); - return { interviewsByTopic: {}, isError: true }; - } + return { interviewsByTopic, isError: false }; }; diff --git a/src/data/helpers/index.test.ts b/src/data/helpers/index.test.ts new file mode 100644 index 0000000..cd1f399 --- /dev/null +++ b/src/data/helpers/index.test.ts @@ -0,0 +1,42 @@ +import { findClosestTopic } from './'; + +describe('findClosestTopic', () => { + const legalTopics = [ + { + name: 'Housing', + codes: ['HO-01-00-00-00', 'HO-02-00-00-00'], + }, + { + name: 'Family', + codes: ['FA-00-00-00-00', 'FA-01-00-00-00'], + }, + { + name: 'Employment', + codes: ['EM-01-00-00-00'], + }, + ]; + + it('should return the exact matching topic', () => { + const topicCode = 'HO-02-00-00-00'; + const result = findClosestTopic(topicCode, legalTopics); + expect(result.name).toBe('Housing'); + }); + + it('should return the closest smaller match if exact match is not available', () => { + const topicCode = 'HO-01-50-00-00'; + const result = findClosestTopic(topicCode, legalTopics); + expect(result.name).toBe('Housing'); + }); + + it('should return undefined if no prefix matches', () => { + const topicCode = 'XX-01-00-00-00'; + const result = findClosestTopic(topicCode, legalTopics); + expect(result).toBeUndefined(); + }); + + it('handles cases with leading zeros in topic codes', () => { + const topicCode = 'FA-01-00-00-00'; + const result = findClosestTopic(topicCode, legalTopics); + expect(result.name).toBe('Family'); + }); +});