diff --git a/app/providers.tsx b/app/providers.tsx index 355f581..acde1f2 100644 --- a/app/providers.tsx +++ b/app/providers.tsx @@ -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( @@ -20,8 +20,10 @@ export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); -} +} \ No newline at end of file diff --git a/components/support/FaqHelpCenter.tsx b/components/support/FaqHelpCenter.tsx index 8526eab..587a445 100644 --- a/components/support/FaqHelpCenter.tsx +++ b/components/support/FaqHelpCenter.tsx @@ -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 ( -
- Loading help articles… + return ( +
+ {/* Language selector */} +
+ +
- ); - } - if (isError) { - return ( -
- Failed to load FAQ. Please try again later. -
- ); - } + {/* States */} + {isLoading && ( +
+ Loading help articles… +
+ )} - if (categories.length === 0) { - return ( -
- No FAQ articles available. -
- ); - } + {isError && ( +
+ Failed to load FAQ. Please try again later. +
+ )} - return ( -
- {categories.map((category) => ( - - ))} + {!isLoading && !isError && categories.length === 0 && ( +
+ No FAQ articles available. +
+ )} + + {!isLoading && !isError && categories.length > 0 && ( +
+ {categories.map((category) => ( + + ))} +
+ )}
); } \ No newline at end of file diff --git a/contexts/LanguageContext.tsx b/contexts/LanguageContext.tsx new file mode 100644 index 0000000..656d9a6 --- /dev/null +++ b/contexts/LanguageContext.tsx @@ -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 = { + en: 'English', + pcm: 'Pidgin', + ha: 'Hausa', + yo: 'Yoruba', + ig: 'Igbo', +}; + +export const LanguageContext = createContext({ + locale: 'en', + setLocale: () => {}, +}); + +export function LanguageProvider({ children }: { children: ReactNode }) { + const [locale, setLocaleState] = useState('en'); + + const setLocale = useCallback((next: Locale) => { + setLocaleState(next); + }, []); + + return ( + + {children} + + ); +} + +export function useLanguageContext() { + return useContext(LanguageContext); +} \ No newline at end of file diff --git a/hooks/__tests__/useLanguage.test.ts b/hooks/__tests__/useLanguage.test.ts new file mode 100644 index 0000000..aa060ec --- /dev/null +++ b/hooks/__tests__/useLanguage.test.ts @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/hooks/useFaq.ts b/hooks/useFaq.ts index 7747ac8..1cd176e 100644 --- a/hooks/useFaq.ts +++ b/hooks/useFaq.ts @@ -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; @@ -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({ - queryKey: FAQ_QUERY_KEY, - queryFn: faqService.getFaqs, + queryKey: FAQ_QUERY_KEY(locale), + queryFn: () => faqService.getFaqs(locale), }); return { diff --git a/hooks/useLanguage.ts b/hooks/useLanguage.ts new file mode 100644 index 0000000..60bf3ec --- /dev/null +++ b/hooks/useLanguage.ts @@ -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, + }; +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json new file mode 100644 index 0000000..714e044 --- /dev/null +++ b/locales/en.json @@ -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." +} \ No newline at end of file diff --git a/locales/ha.json b/locales/ha.json new file mode 100644 index 0000000..4d36fe8 --- /dev/null +++ b/locales/ha.json @@ -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." +} \ No newline at end of file diff --git a/locales/ig.json b/locales/ig.json new file mode 100644 index 0000000..8cfa7b7 --- /dev/null +++ b/locales/ig.json @@ -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." +} \ No newline at end of file diff --git a/locales/pcm.json b/locales/pcm.json new file mode 100644 index 0000000..690f163 --- /dev/null +++ b/locales/pcm.json @@ -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." +} \ No newline at end of file diff --git a/locales/yo.json b/locales/yo.json new file mode 100644 index 0000000..fd8823d --- /dev/null +++ b/locales/yo.json @@ -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." +} \ No newline at end of file diff --git a/services/faqService.ts b/services/faqService.ts index 22d37c3..54723fb 100644 --- a/services/faqService.ts +++ b/services/faqService.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import type { Locale } from '@/contexts/LanguageContext'; const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL ?? ''; @@ -17,9 +18,9 @@ export interface FaqCategory { export type FaqResponse = FaqCategory[]; export const faqService = { - async getFaqs(): Promise { + async getFaqs(locale: Locale = 'en'): Promise { const { data } = await axios.get( - `${API_BASE_URL}/api/faq`, + `${API_BASE_URL}/api/faq?lang=${locale}`, ); return data; }, diff --git a/services/languageService.ts b/services/languageService.ts new file mode 100644 index 0000000..ff8bfe5 --- /dev/null +++ b/services/languageService.ts @@ -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); + }, +}; \ No newline at end of file