import { useMemo, useState } from 'react'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useContentCatalogPayload } from '@/business/content-catalog/hooks'; import { buildDashboardSignOfWeekPayload, buildSignLanguageCategories, filterSignLanguageItems, getSignLanguageProgressPercent, normalizeSignLanguageItems, paginateSignLanguageItems, selectSignLanguageSignOfWeek, } from '@/business/sign-language/selectors'; import { canPersistPersonalScopeResults } from '@/business/scope/selectors'; import type { SignLanguageDraft, SignLanguageStepDraft, SignLanguagePage, SignLanguageVideoModalState, } from '@/business/sign-language/types'; import { useLearnedSignsProgress } from '@/business/user-progress/hooks'; import { updateManagedContentCatalog } from '@/shared/api/contentCatalog'; import { useScopeContext } from '@/shared/app/scope-context'; import { usePermissions } from '@/shared/app/usePermissions'; import { CONTENT_CATALOG_TYPES } from '@/shared/constants/contentCatalog'; import { CONTENT_CATALOG_QUERY_KEYS } from '@/shared/constants/contentCatalog'; import type { SignLanguageCategoryFilter, SignLanguageViewMode, } from '@/shared/constants/signLanguage'; import { getOptionalErrorMessage } from '@/shared/errors/errorMessages'; import type { SignItem, SignLanguagePageContent, } from '@/shared/types/app'; import type { DashboardSignOfWeek } from '@/shared/types/dashboard'; const EMPTY_SIGN_LANGUAGE_PAGE_CONTENT: SignLanguagePageContent = { rememberTitle: '', rememberDescription: '', }; const EMPTY_LEARNED_SIGN_IDS = new Set(); const DEFAULT_SIGN_STEP: SignLanguageStepDraft = { step: 1, instruction: '', duration: 3, }; const DEFAULT_SIGN_DRAFT: SignLanguageDraft = { id: null, word: '', category: 'basic-needs', description: '', image: '', tip: '', videoUrl: '', gifUrl: '', youtubeSearchUrl: '', videoSteps: [DEFAULT_SIGN_STEP], }; function createSignId(): string { return `sign-${crypto.randomUUID()}`; } function draftFromSign(sign: SignItem): SignLanguageDraft { return { id: sign.id, word: sign.word, category: sign.category, description: sign.description, image: sign.image, tip: sign.tip, videoUrl: sign.videoUrl, gifUrl: sign.gifUrl, youtubeSearchUrl: sign.youtubeSearchUrl ?? '', videoSteps: sign.videoSteps.map((step, index) => ({ step: step.step || index + 1, instruction: step.instruction, duration: step.duration, })), }; } function signFromDraft(draft: SignLanguageDraft): SignItem { return { id: draft.id ?? createSignId(), word: draft.word.trim(), category: draft.category, description: draft.description.trim(), image: draft.image.trim(), tip: draft.tip.trim(), videoUrl: draft.videoUrl.trim(), gifUrl: draft.gifUrl.trim(), youtubeSearchUrl: draft.youtubeSearchUrl.trim() || undefined, videoSteps: draft.videoSteps .map((step, index) => ({ step: index + 1, instruction: step.instruction.trim(), duration: Number.isFinite(step.duration) && step.duration > 0 ? step.duration : 3, })) .filter((step) => step.instruction.length > 0), }; } function validateSignDraft(draft: SignLanguageDraft): string | null { if (!draft.word.trim()) return 'Title is required.'; if (!draft.description.trim()) return 'Description is required.'; if (!draft.image.trim()) return 'Preview image URL or upload is required.'; if (!draft.tip.trim()) return 'Teaching tip is required.'; if (!draft.videoUrl.trim()) return 'YouTube video URL is required.'; if (draft.videoSteps.every((step) => !step.instruction.trim())) { return 'At least one guide step is required.'; } return null; } export function useSignLanguagePage(): SignLanguagePage { const { effectiveTenant, ownTenant, selectedTenant } = useScopeContext(); const permissions = usePermissions(); const queryClient = useQueryClient(); const canPersistProgress = canPersistPersonalScopeResults(ownTenant, selectedTenant); const canManageSigns = effectiveTenant?.level === 'organization' && permissions.has('MANAGE_CONTENT_CATALOG'); const signsQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.signLanguageItems, [], ); const signOfWeekQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.dashboardSignOfWeek, null, ); const pageContentQuery = useContentCatalogPayload( CONTENT_CATALOG_TYPES.signLanguagePageContent, EMPTY_SIGN_LANGUAGE_PAGE_CONTENT, ); const progress = useLearnedSignsProgress({ enabled: canPersistProgress }); const [searchQuery, setSearchQuery] = useState(''); const [categoryFilter, setCategoryFilter] = useState('all'); const [currentPage, setCurrentPage] = useState(1); const [selectedSignId, setSelectedSignId] = useState(null); const [signDraft, setSignDraft] = useState(null); const [signDraftMode, setSignDraftMode] = useState<'create' | 'edit' | null>(null); const [signDraftError, setSignDraftError] = useState(null); const [signSaveMessage, setSignSaveMessage] = useState(null); const [pendingDeleteSign, setPendingDeleteSign] = useState(null); const signs = useMemo( () => normalizeSignLanguageItems(signsQuery.payload), [signsQuery.payload], ); const signOfWeek = useMemo( () => selectSignLanguageSignOfWeek(signs, signOfWeekQuery.payload), [signOfWeekQuery.payload, signs], ); const saveSigns = useMutation({ mutationFn: async (nextSigns: readonly SignItem[]) => updateManagedContentCatalog( CONTENT_CATALOG_TYPES.signLanguageItems, { payload: nextSigns }, ), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.signLanguageItems], }); setSignSaveMessage('Sign cards saved.'); }, }); const saveSignOfWeek = useMutation({ mutationFn: async (sign: SignItem) => updateManagedContentCatalog( CONTENT_CATALOG_TYPES.dashboardSignOfWeek, { payload: buildDashboardSignOfWeekPayload(sign) }, ), onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: [...CONTENT_CATALOG_QUERY_KEYS.content, CONTENT_CATALOG_TYPES.dashboardSignOfWeek], }); setSignSaveMessage('Sign of the week saved.'); }, }); const filters = useMemo( () => ({ searchQuery, categoryFilter, }), [categoryFilter, searchQuery], ); const categories = useMemo( () => buildSignLanguageCategories(signs), [signs], ); const filteredSigns = useMemo( () => filterSignLanguageItems(signs, filters), [filters, signs], ); const pagination = useMemo( () => paginateSignLanguageItems(filteredSigns, currentPage), [currentPage, filteredSigns], ); const selectedSign = useMemo( () => signs.find((sign) => sign.id === selectedSignId) ?? null, [selectedSignId, signs], ); const learnedSignIds = canPersistProgress ? progress.learnedSignIds : EMPTY_LEARNED_SIGN_IDS; const progressPercent = useMemo( () => getSignLanguageProgressPercent(signs, learnedSignIds), [learnedSignIds, signs], ); async function toggleLearned(id: string) { if (!canPersistProgress) { return; } const sign = signs.find((item) => item.id === id); if (!sign) { return; } await progress.toggleLearnedSign(id, sign.word); } function startCreateSign() { if (!canManageSigns) return; setSignDraft(DEFAULT_SIGN_DRAFT); setSignDraftMode('create'); setSignDraftError(null); setSignSaveMessage(null); } function startEditSign(sign: SignItem) { if (!canManageSigns) return; setSignDraft(draftFromSign(sign)); setSignDraftMode('edit'); setSignDraftError(null); setSignSaveMessage(null); } function updateSignDraft(updates: Partial) { setSignDraft((current) => current ? { ...current, ...updates } : current); setSignDraftError(null); setSignSaveMessage(null); } function updateSignDraftStep(index: number, updates: Partial) { setSignDraft((current) => current ? { ...current, videoSteps: current.videoSteps.map((step, stepIndex) => stepIndex === index ? { ...step, ...updates } : step, ), } : current); setSignDraftError(null); setSignSaveMessage(null); } function addSignDraftStep() { setSignDraft((current) => current ? { ...current, videoSteps: [ ...current.videoSteps, { ...DEFAULT_SIGN_STEP, step: current.videoSteps.length + 1 }, ], } : current); } function removeSignDraftStep(index: number) { setSignDraft((current) => current ? { ...current, videoSteps: current.videoSteps .filter((_, stepIndex) => stepIndex !== index) .map((step, stepIndex) => ({ ...step, step: stepIndex + 1 })), } : current); } function cancelSignDraft() { setSignDraft(null); setSignDraftMode(null); setSignDraftError(null); } function saveSignDraft() { if (!canManageSigns || !signDraft || !signDraftMode) return; const validationError = validateSignDraft(signDraft); if (validationError) { setSignDraftError(validationError); return; } const nextSign = signFromDraft(signDraft); const nextSigns = signDraftMode === 'create' ? [...signs, nextSign] : signs.map((sign) => sign.id === nextSign.id ? nextSign : sign); void saveSigns.mutateAsync(nextSigns).then(() => { setSignDraft(null); setSignDraftMode(null); setSignDraftError(null); }).catch(() => undefined); } function requestDeleteSign(sign: SignItem) { if (!canManageSigns) return; setPendingDeleteSign(sign); } function cancelDeleteSign() { setPendingDeleteSign(null); } function confirmDeleteSign() { if (!canManageSigns || !pendingDeleteSign) return; const id = pendingDeleteSign.id; const nextSigns = signs.filter((sign) => sign.id !== id); void saveSigns.mutateAsync(nextSigns).then(() => { setPendingDeleteSign(null); if (signDraft?.id === id) cancelSignDraft(); if (selectedSignId === id) setSelectedSignId(null); }).catch(() => undefined); } function selectSignOfWeek(signId: string) { if (!canManageSigns) return; const sign = signs.find((item) => item.id === signId); if (!sign) return; void saveSignOfWeek.mutateAsync(sign).catch(() => undefined); } function updateSearchQuery(value: string) { setSearchQuery(value); setCurrentPage(1); } function updateCategoryFilter(value: SignLanguageCategoryFilter) { setCategoryFilter(value); setCurrentPage(1); } function updatePage(page: number) { setCurrentPage(paginateSignLanguageItems(filteredSigns, page).currentPage); } return { signs, filteredSigns, visibleSigns: pagination.items, pagination, categories, filters, learnedSignIds, learnedCount: learnedSignIds.size, progressPercent, canPersistProgress, canManageSigns, selectedSign, signOfWeekId: signOfWeek?.id ?? null, signDraft, signDraftMode, signDraftError, signManagementError: saveSigns.error || saveSignOfWeek.error, signSaveMessage, isSavingSigns: saveSigns.isPending || saveSignOfWeek.isPending, pendingDeleteSign, pageContent: pageContentQuery.payload, isLoading: signsQuery.isLoading || signOfWeekQuery.isLoading || pageContentQuery.isLoading || (canPersistProgress && progress.isLoading), isSaving: (canPersistProgress && progress.isSaving) || saveSigns.isPending || saveSignOfWeek.isPending, signsError: signsQuery.error || signOfWeekQuery.error, pageContentError: pageContentQuery.error, progressErrorMessage: getOptionalErrorMessage(progress.error), setSearchQuery: updateSearchQuery, clearSearch: () => updateSearchQuery(''), setCategoryFilter: updateCategoryFilter, setPage: updatePage, selectSign: setSelectedSignId, closeSign: () => setSelectedSignId(null), toggleLearned, startCreateSign, startEditSign, updateSignDraft, updateSignDraftStep, addSignDraftStep, removeSignDraftStep, cancelSignDraft, saveSignDraft, requestDeleteSign, cancelDeleteSign, confirmDeleteSign, selectSignOfWeek, }; } export function useSignLanguageVideoModalState(): SignLanguageVideoModalState { const [showSteps, setShowSteps] = useState(false); const [viewMode, setViewMode] = useState('gif'); const [gifLoaded, setGifLoaded] = useState(false); const [gifError, setGifError] = useState(false); return { showSteps, viewMode, gifLoaded, gifError, showStepGuide: () => setShowSteps(true), hideStepGuide: () => setShowSteps(false), toggleStepGuide: () => setShowSteps((current) => !current), setViewMode, markGifLoaded: () => setGifLoaded(true), markGifFailed: () => { setGifError(true); setGifLoaded(true); }, }; }