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 src/components/Collaboration/Collaboration.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ApiEndpoint } from '~/utils/URL';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import OneCommunityImage from '../../assets/images/logo2.png';
import FAQSection from './FAQSection';

const ADS_PER_PAGE = 18;

Expand Down Expand Up @@ -326,6 +327,8 @@ function Collaboration() {
</button>
))}
</div>

<FAQSection />
</div>

{/* MODAL */}
Expand Down
96 changes: 96 additions & 0 deletions src/components/Collaboration/FAQSection.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { useEffect, useState } from 'react';
import DOMPurify from 'dompurify';
import { useSelector } from 'react-redux';
import { toast } from 'react-toastify';
import { getAllFAQs } from '../Faq/api';
import styles from './FAQSection.module.css';

// Held here rather than in the database so the section always has something to
// show, including for visitors who cannot load the questions.
const VIDEO_FAQ = {
question: 'What is it like working with us?',
videoUrl: 'https://www.youtube.com/embed/L7MUY0IJ4FY',
};

function FAQSection() {
const [faqs, setFaqs] = useState([]);
const [requiresSignIn, setRequiresSignIn] = useState(false);
const darkMode = useSelector(state => state.theme.darkMode);

useEffect(() => {
const fetchFaqs = async () => {
try {
const res = await getAllFAQs();
setFaqs(
res.data.filter(
faq => faq.question.trim().toLowerCase() !== VIDEO_FAQ.question.toLowerCase(),
),
);
} catch (error) {
// This page is public but GET /faqs requires a token, so a signed-out
// visitor gets a 401 here. Show an inline note rather than a toast,
// which would otherwise fire on every anonymous page load.
if (error.response?.status === 401) {
setRequiresSignIn(true);
} else {
toast.error('Error fetching FAQs');
}
}
};
fetchFaqs();
}, []);

return (
<div
className={`${styles.faqContainer} ${darkMode ? styles.dark : ''}`}
data-testid="faq-section"
>
<h2>Please read this before applying / FAQ</h2>
<div className={styles.faqTwoColumn}>
{/* Left column: FAQ list */}
<div className={styles.faqLeft}>
{requiresSignIn ? (
<p className={styles.faqNotice}>Sign in to read the frequently asked questions.</p>
) : (
faqs.map((faq, idx) => (
<div key={faq._id} className={styles.faqItem}>
<div className={styles.faqQuestion}>
<strong>
{idx + 1}. {faq.question}
</strong>
</div>
<div
className={styles.faqAnswer}
// Answers are authored by admins through FAQ management and may
// contain formatting, so they are rendered as HTML. Sanitised
// because this page is reachable without signing in.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(faq.answer || '') }}
/>
</div>
))
)}
</div>
{/* Right column: Video FAQ */}
<div className={styles.faqRight}>
<div className={styles.faqItem}>
<div className={styles.faqQuestion}>
<strong>{VIDEO_FAQ.question}</strong>
</div>
<div className={styles.videoWrapper}>
<iframe
className={styles.videoIframe}
src={VIDEO_FAQ.videoUrl}
title={VIDEO_FAQ.question}
allowFullScreen
loading="lazy"
/>
</div>
</div>
</div>
</div>
</div>
);
}

export default FAQSection;
91 changes: 91 additions & 0 deletions src/components/Collaboration/FAQSection.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/* FAQSection.module.css */

.faqContainer {
margin-top: 2rem;
}

.faqTwoColumn {
display: flex;
gap: 2rem;
margin-top: 2rem;
}

.faqLeft {
flex: 2;
}

.faqRight {
flex: 1;
min-width: 0; /* Prevents flex item from overflowing */
}

.faqItem {
margin-bottom: 1.5em;
}

.faqQuestion {
font-weight: bold;
margin-bottom: 0.5em;
}

.faqAnswer {
margin-top: 0.5em;
}

.faqNotice {
font-style: italic;
color: #64748b;
}

.videoWrapper {
margin-top: 1em;
position: relative;
width: 100%;
height: 0;
padding-bottom: 56.25%; /* 16:9 aspect ratio */
}

.videoIframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: none;
}

/* Dark mode. Scoped to .faqContainer so nothing here can reach the rest of the
job listing page, which has its own dark mode rules. */
.faqContainer.dark h2,
.faqContainer.dark .faqQuestion,
.faqContainer.dark .faqAnswer {
color: #e5e7eb;
}

.faqContainer.dark .faqAnswer a {
color: #7dd3fc;
}

.faqContainer.dark .faqNotice {
color: #94a3b8;
}

/* Mobile responsiveness */
@media (width <= 768px) {
.faqTwoColumn {
flex-direction: column;
gap: 1rem;
}

.faqLeft {
flex: none;
}

.faqRight {
flex: none;
}

.videoWrapper {
padding-bottom: 56.25%; /* Maintain 16:9 aspect ratio on mobile */
}
}
124 changes: 124 additions & 0 deletions src/components/Collaboration/__tests__/FAQSection.test.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { render, screen, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Provider } from 'react-redux';
import { configureStore } from '@reduxjs/toolkit';
import { toast } from 'react-toastify';
import FAQSection from '../FAQSection';
import { getAllFAQs } from '../../Faq/api';
import styles from '../FAQSection.module.css';

vi.mock('../../Faq/api', () => ({
getAllFAQs: vi.fn(),
}));

vi.mock('react-toastify', () => ({
toast: { error: vi.fn() },
}));

const buildStore = (darkMode = false) =>
configureStore({
reducer: {
theme: () => ({ darkMode }),
},
});

const setUpSection = (darkMode = false) =>
render(
<Provider store={buildStore(darkMode)}>
<FAQSection />
</Provider>,
);

describe('FAQSection', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('lists the questions returned by the API, numbered from one', async () => {
getAllFAQs.mockResolvedValue({
data: [
{ _id: '1', question: 'How do I volunteer?', answer: 'Fill in the form.' },
{ _id: '2', question: 'Is there a time commitment?', answer: '10 hours a week.' },
],
});

setUpSection();

expect(await screen.findByText(/1\. How do I volunteer\?/)).toBeInTheDocument();
expect(screen.getByText(/2\. Is there a time commitment\?/)).toBeInTheDocument();
expect(screen.getByText('Fill in the form.')).toBeInTheDocument();
});

it('drops the video question from the list so it is not shown twice', async () => {
getAllFAQs.mockResolvedValue({
data: [
{ _id: '1', question: 'What is it like working with us?', answer: 'See the video.' },
{ _id: '2', question: 'How do I volunteer?', answer: 'Fill in the form.' },
],
});

setUpSection();

expect(await screen.findByText(/1\. How do I volunteer\?/)).toBeInTheDocument();
// The video card keeps the question as its own heading, so it appears once.
expect(screen.getAllByText('What is it like working with us?')).toHaveLength(1);
expect(screen.queryByText('See the video.')).not.toBeInTheDocument();
});

it('strips event handlers out of answers while keeping their formatting', async () => {
getAllFAQs.mockResolvedValue({
data: [
{
_id: '1',
question: 'Where is the form?',
answer:
'<p>On the <a href="/x">site</a>.</p><img src="x" onerror="window.hacked = true">',
},
],
});

setUpSection();

// The markup still renders, so answers keep their links and formatting.
expect(await screen.findByRole('link', { name: 'site' })).toBeInTheDocument();
// But the injected handler is gone, which it would not be without sanitising.
expect(screen.getByRole('img')).not.toHaveAttribute('onerror');
});

it('shows the video card even before any questions load', () => {
getAllFAQs.mockResolvedValue({ data: [] });

setUpSection();

expect(screen.getByTitle('What is it like working with us?')).toBeInTheDocument();
});

it('asks the visitor to sign in when the API rejects the request, without a toast', async () => {
getAllFAQs.mockRejectedValue({ response: { status: 401 } });

setUpSection();

expect(
await screen.findByText(/Sign in to read the frequently asked questions/),
).toBeInTheDocument();
expect(toast.error).not.toHaveBeenCalled();
});

it('reports any other failure as an FAQ error', async () => {
getAllFAQs.mockRejectedValue({ response: { status: 500 } });

setUpSection();

await waitFor(() => expect(toast.error).toHaveBeenCalledWith('Error fetching FAQs'));
expect(screen.queryByText(/Sign in to read/)).not.toBeInTheDocument();
});

it('applies the dark mode class when dark mode is on', async () => {
getAllFAQs.mockResolvedValue({ data: [] });

setUpSection(true);

await waitFor(() => expect(getAllFAQs).toHaveBeenCalled());
expect(screen.getByTestId('faq-section')).toHaveClass(styles.dark);
});
});
Loading