417 lines
13 KiB
TypeScript

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<string>();
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<readonly SignItem[]>(
CONTENT_CATALOG_TYPES.signLanguageItems,
[],
);
const signOfWeekQuery = useContentCatalogPayload<DashboardSignOfWeek | null>(
CONTENT_CATALOG_TYPES.dashboardSignOfWeek,
null,
);
const pageContentQuery = useContentCatalogPayload<SignLanguagePageContent>(
CONTENT_CATALOG_TYPES.signLanguagePageContent,
EMPTY_SIGN_LANGUAGE_PAGE_CONTENT,
);
const progress = useLearnedSignsProgress({ enabled: canPersistProgress });
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<SignLanguageCategoryFilter>('all');
const [currentPage, setCurrentPage] = useState(1);
const [selectedSignId, setSelectedSignId] = useState<string | null>(null);
const [signDraft, setSignDraft] = useState<SignLanguageDraft | null>(null);
const [signDraftMode, setSignDraftMode] = useState<'create' | 'edit' | null>(null);
const [signDraftError, setSignDraftError] = useState<string | null>(null);
const [signSaveMessage, setSignSaveMessage] = useState<string | null>(null);
const [pendingDeleteSign, setPendingDeleteSign] = useState<SignItem | null>(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<readonly SignItem[]>(
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<DashboardSignOfWeek>(
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<SignLanguageDraft>) {
setSignDraft((current) => current ? { ...current, ...updates } : current);
setSignDraftError(null);
setSignSaveMessage(null);
}
function updateSignDraftStep(index: number, updates: Partial<SignLanguageStepDraft>) {
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<SignLanguageViewMode>('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);
},
};
}