Skip to content
Merged
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
8 changes: 5 additions & 3 deletions app/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
'use client';

import { useState } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from 'next-themes';
import { LanguageProvider } from '@/contexts/LanguageContext';

export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
Expand All @@ -20,8 +20,10 @@ export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
{children}
<LanguageProvider>
{children}
</LanguageProvider>
</ThemeProvider>
</QueryClientProvider>
);
}
}
90 changes: 62 additions & 28 deletions components/support/FaqHelpCenter.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,79 @@
'use client';

import { useFaq } from '@/hooks/useFaq';
import { useLanguage } from '@/hooks/useLanguage';
import { LANGUAGE_LABELS, type Locale } from '@/contexts/LanguageContext';
import { AccordionSection } from './AccordionSection';
import { useFaq } from '@/hooks/useFaq';

const LOCALES = Object.keys(LANGUAGE_LABELS) as Locale[];

/**
* FaqHelpCenter — top-level FAQ component.
* Consumes useFaq hook, handles loading/error/empty states,
* and renders categorised AccordionSection components.
* Includes a language dropdown for Pidgin/Hausa/Yoruba/Igbo/English.
*/
export function FaqHelpCenter() {
const { categories, isLoading, isError } = useFaq();
const { locale, setLocale } = useLanguage();
const { categories, isLoading, isError } = useFaq(locale);

if (isLoading) {
return (
<div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
Loading help articles…
return (
<div>
{/* Language selector */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '1.5rem' }}>
<label
htmlFor="language-select"
style={{ marginRight: '0.5rem', fontWeight: 600, color: '#374151', alignSelf: 'center' }}
>
Language:
</label>
<select
id="language-select"
value={locale}
onChange={(e) => setLocale(e.target.value as Locale)}
style={{
border: '1px solid #d1d5db',
borderRadius: '0.375rem',
padding: '0.375rem 0.75rem',
fontSize: '0.875rem',
color: '#111827',
backgroundColor: '#fff',
cursor: 'pointer',
}}
>
{LOCALES.map((l) => (
<option key={l} value={l}>
{LANGUAGE_LABELS[l]}
</option>
))}
</select>
</div>
);
}

if (isError) {
return (
<div style={{ padding: '2rem', textAlign: 'center', color: '#ef4444' }}>
Failed to load FAQ. Please try again later.
</div>
);
}
{/* States */}
{isLoading && (
<div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
Loading help articles…
</div>
)}

if (categories.length === 0) {
return (
<div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
No FAQ articles available.
</div>
);
}
{isError && (
<div style={{ padding: '2rem', textAlign: 'center', color: '#ef4444' }}>
Failed to load FAQ. Please try again later.
</div>
)}

return (
<div>
{categories.map((category) => (
<AccordionSection key={category.id} category={category} />
))}
{!isLoading && !isError && categories.length === 0 && (
<div style={{ padding: '2rem', textAlign: 'center', color: '#6b7280' }}>
No FAQ articles available.
</div>
)}

{!isLoading && !isError && categories.length > 0 && (
<div>
{categories.map((category) => (
<AccordionSection key={category.id} category={category} />
))}
</div>
)}
</div>
);
}
40 changes: 40 additions & 0 deletions contexts/LanguageContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
'use client';
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';

export type Locale = 'en' | 'pcm' | 'ha' | 'yo' | 'ig';

export interface LanguageContextValue {
locale: Locale;
setLocale: (locale: Locale) => void;
}

export const LANGUAGE_LABELS: Record<Locale, string> = {
en: 'English',
pcm: 'Pidgin',
ha: 'Hausa',
yo: 'Yoruba',
ig: 'Igbo',
};

export const LanguageContext = createContext<LanguageContextValue>({
locale: 'en',
setLocale: () => {},
});

export function LanguageProvider({ children }: { children: ReactNode }) {
const [locale, setLocaleState] = useState<Locale>('en');

const setLocale = useCallback((next: Locale) => {
setLocaleState(next);
}, []);

return (
<LanguageContext.Provider value={{ locale, setLocale }}>
{children}
</LanguageContext.Provider>
);
}

export function useLanguageContext() {
return useContext(LanguageContext);
}
67 changes: 67 additions & 0 deletions hooks/__tests__/useLanguage.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { renderHook, act } from '@testing-library/react';
import { useLanguage } from '@/hooks/useLanguage';
import { languageService } from '@/services/languageService';
import { LanguageProvider } from '@/contexts/LanguageContext';
import React from 'react';

jest.mock('@/services/languageService', () => ({
languageService: {
getLanguage: jest.fn(),
saveLanguage: jest.fn(),
},
}));

const wrapper = ({ children }: { children: React.ReactNode }) =>
React.createElement(LanguageProvider, null, children);

describe('useLanguage', () => {
beforeEach(() => {
jest.clearAllMocks();
(languageService.getLanguage as jest.Mock).mockReturnValue('en');
});

it('returns default locale as en', () => {
const { result } = renderHook(() => useLanguage(), { wrapper });
expect(result.current.locale).toBe('en');
});

it('updates locale when setLocale is called', () => {
const { result } = renderHook(() => useLanguage(), { wrapper });

act(() => {
result.current.setLocale('yo');
});

expect(result.current.locale).toBe('yo');
});

it('persists locale via languageService when setLocale is called', () => {
const { result } = renderHook(() => useLanguage(), { wrapper });

act(() => {
result.current.setLocale('ha');
});

expect(languageService.saveLanguage).toHaveBeenCalledWith('ha');
});

it('restores saved locale on mount', () => {
(languageService.getLanguage as jest.Mock).mockReturnValue('pcm');

const { result } = renderHook(() => useLanguage(), { wrapper });

expect(result.current.locale).toBe('pcm');
});

it('supports all five locales', () => {
const { result } = renderHook(() => useLanguage(), { wrapper });
const locales = ['en', 'pcm', 'ha', 'yo', 'ig'] as const;

locales.forEach((l) => {
act(() => {
result.current.setLocale(l);
});
expect(result.current.locale).toBe(l);
});
});
});
12 changes: 7 additions & 5 deletions hooks/useFaq.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useQuery } from '@tanstack/react-query';
import { faqService, type FaqResponse } from '@/services/faqService';
import type { Locale } from '@/contexts/LanguageContext';

export const FAQ_QUERY_KEY = ['faq'] as const;
export const FAQ_QUERY_KEY = (locale: Locale) => ['faq', locale] as const;

export interface UseFaqReturn {
categories: FaqResponse;
Expand All @@ -12,12 +13,13 @@ export interface UseFaqReturn {

/**
* useFaq — fetches FAQ categories and items from the backend API.
* Wraps faqService with TanStack Query for caching and loading states.
* Accepts a locale so the query key changes when language switches,
* triggering a fresh fetch for localised content.
*/
export function useFaq(): UseFaqReturn {
export function useFaq(locale: Locale = 'en'): UseFaqReturn {
const { data, isLoading, isError, error } = useQuery<FaqResponse, Error>({
queryKey: FAQ_QUERY_KEY,
queryFn: faqService.getFaqs,
queryKey: FAQ_QUERY_KEY(locale),
queryFn: () => faqService.getFaqs(locale),
});

return {
Expand Down
31 changes: 31 additions & 0 deletions hooks/useLanguage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { useCallback, useEffect } from 'react';
import { useLanguageContext, type Locale } from '@/contexts/LanguageContext';
import { languageService } from '@/services/languageService';
import type { Locale as LocaleType } from '@/contexts/LanguageContext';

export interface UseLanguageReturn {
locale: LocaleType;
setLocale: (locale: LocaleType) => void;
}

export function useLanguage(): UseLanguageReturn {
const { locale, setLocale } = useLanguageContext();

useEffect(() => {
const saved = languageService.getLanguage();
if (saved !== locale) {
setLocale(saved);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

const handleSetLocale = useCallback((next: Locale) => {
languageService.saveLanguage(next);
setLocale(next);
}, [setLocale]);

return {
locale,
setLocale: handleSetLocale,
};
}
8 changes: 8 additions & 0 deletions locales/en.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"languageLabel": "Language",
"loading": "Loading help articles…",
"error": "Failed to load FAQ. Please try again later.",
"empty": "No FAQ articles available.",
"pageTitle": "FAQ & Help Center",
"pageSubtitle": "Find answers to common questions about SwiftChain."
}
8 changes: 8 additions & 0 deletions locales/ha.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"languageLabel": "Harshe",
"loading": "Ana loda kasidu na taimako…",
"error": "An kasa loda FAQ. Da fatan za a sake gwadawa daga baya.",
"empty": "Babu kasidu na FAQ.",
"pageTitle": "Cibiyar Taimako ta FAQ",
"pageSubtitle": "Sami amsa ga tambayoyi game da SwiftChain."
}
8 changes: 8 additions & 0 deletions locales/ig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"languageLabel": "Asụsụ",
"loading": "A na-ebunye isiokwu enyemaka…",
"error": "Ọ dịghị ike ibunye FAQ. Biko nwalee ọzọ.",
"empty": "Enweghị isiokwu FAQ dị ebe a.",
"pageTitle": "Ebe Enyemaka FAQ",
"pageSubtitle": "Chọta azịza maka ajụjụ gbasara SwiftChain."
}
8 changes: 8 additions & 0 deletions locales/pcm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"languageLabel": "Language",
"loading": "We dey load the help articles…",
"error": "E no work. Abeg try again later.",
"empty": "No FAQ articles dey here.",
"pageTitle": "FAQ & Help Center",
"pageSubtitle": "Find answer to questions wey you get about SwiftChain."
}
8 changes: 8 additions & 0 deletions locales/yo.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"languageLabel": "Ede",
"loading": "A n gbe awọn nkan iranlọwọ wọle…",
"error": "A kuna lati gbe FAQ wọle. Jọwọ gbiyanju lẹẹkansi.",
"empty": "Ko si awọn nkan FAQ ti o wa.",
"pageTitle": "Ile-iṣẹ Iranlọwọ FAQ",
"pageSubtitle": "Wa awọn idahun si awọn ibeere nipa SwiftChain."
}
5 changes: 3 additions & 2 deletions services/faqService.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios from 'axios';
import type { Locale } from '@/contexts/LanguageContext';

const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? '';

Expand All @@ -17,9 +18,9 @@ export interface FaqCategory {
export type FaqResponse = FaqCategory[];

export const faqService = {
async getFaqs(): Promise<FaqResponse> {
async getFaqs(locale: Locale = 'en'): Promise<FaqResponse> {
const { data } = await axios.get<FaqResponse>(
`${API_BASE_URL}/api/faq`,
`${API_BASE_URL}/api/faq?lang=${locale}`,
);
return data;
},
Expand Down
15 changes: 15 additions & 0 deletions services/languageService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { Locale } from '@/contexts/LanguageContext';

const STORAGE_KEY = 'swiftchain-language';

export const languageService = {
getLanguage(): Locale {
if (typeof window === 'undefined') return 'en';
return (localStorage.getItem(STORAGE_KEY) as Locale) ?? 'en';
},

saveLanguage(locale: Locale): void {
if (typeof window === 'undefined') return;
localStorage.setItem(STORAGE_KEY, locale);
},
};
Loading