diff --git a/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts b/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts index f1248d113..c3d5fea3e 100644 --- a/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts +++ b/frontend-next-migration/src/app/[lng]/(helper)/defense-gallery/[herogroup]/_getPage.ts @@ -1,14 +1,17 @@ import { createPage } from '@/app/_helpers'; import { getServerTranslation } from '@/shared/i18n'; import { notFound } from 'next/navigation'; -import { HeroGroup } from '@/entities/Hero'; -import { initializeHeroGroups } from '@/entities/Hero/model/initializeHeroGroups'; +import { HeroGroup, GroupInfo } from '@/entities/Hero'; +import { + initializeHeroGroups, + initializeHeroGroupsFromDirectus, +} from '@/entities/Hero/model/initializeHeroGroups'; import { StaticImageData } from 'next/image'; import { SingleDefensePageProps } from '@/preparedPages/DefenseGalleryPages'; import { getRouteDefenseGalleryGroupPage } from '@/shared/appLinks/RoutePaths'; import { baseUrl, defaultOpenGraph } from '@/shared/seoConstants'; -function getOgImageUrl(info?: ReturnType[HeroGroup]) { +function getOgImageUrl(info?: GroupInfo) { const candidate = (info?.srcImg as string | StaticImageData | undefined) ?? (info?.heroes?.[0]?.srcImg as string | StaticImageData | undefined) ?? @@ -27,7 +30,17 @@ export async function _getPage(lng: string, heroGroup: string) { } const group = heroGroup as HeroGroup; - const groups = initializeHeroGroups(t); + // Try to fetch from Directus first, fallback to static data + let groups: Record; + try { + groups = await initializeHeroGroupsFromDirectus(lng as 'en' | 'fi' | 'ru'); + // If Directus returns empty, fallback to static + if (Object.keys(groups).length === 0) { + groups = initializeHeroGroups(t); + } + } catch { + groups = initializeHeroGroups(t); + } const info = groups[group]; const groupName = info?.name ?? group; diff --git a/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts b/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts index 348cfc8ee..6dc8f55af 100644 --- a/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts +++ b/frontend-next-migration/src/app/[lng]/(helper)/hero-development/[slug]/_getPage.ts @@ -14,10 +14,22 @@ function getOgImageUrl(hero: HeroWithGroup) { export async function _getPage(lng: string, slug: string) { const { t } = await getServerTranslation(lng, 'heroes'); const heroManager = new HeroManager(t); - const currentHero = heroManager.getHeroBySlug(slug as HeroSlug); + + // Initialize from Directus first + await heroManager.initializeFromDirectus(lng as 'en' | 'fi' | 'ru'); + + // Try to get hero from Directus, fallback to static data + let currentHero = await heroManager.getHeroBySlugAsync( + slug as HeroSlug, + lng as 'en' | 'fi' | 'ru', + ); + if (!currentHero) { + currentHero = heroManager.getHeroBySlug(slug as HeroSlug); + } if (!currentHero) { notFound(); } + currentHero.stats.forEach((stat) => { stat.color = color[stat.name]; }); diff --git a/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts b/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts index 8a5450ae3..d56254297 100644 --- a/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts +++ b/frontend-next-migration/src/app/[lng]/(helper)/heroes/[slug]/_getPage.ts @@ -11,25 +11,48 @@ function getOgImageUrl(hero: HeroWithGroup) { return /^https?:\/\//i.test(src) ? src : `${baseUrl}${src}`; } -export async function _getPage(lng: string, slug: string) { - const { t } = await getServerTranslation(lng, 'heroes'); - const heroManager = new HeroManager(t); - - const currentHero = heroManager.getHeroBySlug(slug as HeroSlug); +async function getCurrentHero( + heroManager: HeroManager, + slug: string, + lng: string, +): Promise { + const locale = lng as 'en' | 'fi' | 'ru'; + let currentHero = await heroManager.getHeroBySlugAsync(slug as HeroSlug, locale); + if (!currentHero) { + // eslint-disable-next-line no-console + console.log(`[heroes/_getPage] Hero "${slug}" not found in Directus, trying static data`); + currentHero = heroManager.getHeroBySlug(slug as HeroSlug); + } if (!currentHero) { + // eslint-disable-next-line no-console + console.error(`[heroes/_getPage] Hero "${slug}" not found in Directus or static data`); notFound(); } + return currentHero; +} - const heroes = heroManager.getAllHeroes(); +function getNavigationHeroes( + heroes: HeroWithGroup[], + currentHero: HeroWithGroup, +): { prevHero: HeroWithGroup; nextHero: HeroWithGroup } { + const currentHeroIndex = heroes.findIndex((hero) => hero.id === currentHero.id); const prevHero = - heroManager.getHeroBeforeSpecificHero(currentHero.id) || (heroes.at(-1) as HeroWithGroup); + currentHeroIndex > 0 + ? heroes[currentHeroIndex - 1] + : (heroes.at(-1) as HeroWithGroup | undefined); const nextHero = - heroManager.getHeroAfterSpecificHero(currentHero.id) || (heroes.at(0) as HeroWithGroup); + currentHeroIndex < heroes.length - 1 + ? heroes[currentHeroIndex + 1] + : (heroes.at(0) as HeroWithGroup | undefined); - const prevHeroLink = getRouteOneHeroPage(prevHero.slug); - const nextHeroLink = getRouteOneHeroPage(nextHero.slug); + // Fallback to current hero if navigation heroes not found + return { + prevHero: prevHero || currentHero, + nextHero: nextHero || currentHero, + }; +} - // Routes & SEO +function buildSeoData(currentHero: HeroWithGroup, lng: string, t: (key: string) => string) { const relPath = getRouteOneHeroPage(encodeURIComponent(currentHero.slug)); const path = `/${lng}${relPath}`; const title = currentHero.title; @@ -41,6 +64,37 @@ export async function _getPage(lng: string, slug: string) { : null; const ogImages = ogImage ? [ogImage] : (defaultOpenGraph.images ?? []); + return { + title, + description, + keywords, + path, + ogImages, + }; +} + +export async function _getPage(lng: string, slug: string) { + const { t } = await getServerTranslation(lng, 'heroes'); + const heroManager = new HeroManager(t); + + // Initialize from Directus first (this may fail silently and use static data) + await heroManager.initializeFromDirectus(lng as 'en' | 'fi' | 'ru'); + + // Get current hero with fallback + const currentHero = await getCurrentHero(heroManager, slug, lng); + + // Get all heroes for navigation (try Directus first, fallback to static) + let heroes = await heroManager.getAllHeroesFromDirectus(lng as 'en' | 'fi' | 'ru'); + if (heroes.length === 0) { + heroes = heroManager.getAllHeroes(); + } + + const { prevHero, nextHero } = getNavigationHeroes(heroes, currentHero); + const prevHeroLink = getRouteOneHeroPage(prevHero.slug); + const nextHeroLink = getRouteOneHeroPage(nextHero.slug); + + const seoData = buildSeoData(currentHero, lng, t); + return createPage({ buildPage: () => ({ slug: currentHero.slug, @@ -49,18 +103,18 @@ export async function _getPage(lng: string, slug: string) { nextHeroLink: nextHeroLink, }), buildSeo: () => ({ - title, - description, - keywords, + title: seoData.title, + description: seoData.description, + keywords: seoData.keywords, openGraph: { ...defaultOpenGraph, type: 'website', - title, - description, - url: path, - images: ogImages, + title: seoData.title, + description: seoData.description, + url: seoData.path, + images: seoData.ogImages, }, - alternates: { canonical: path }, + alternates: { canonical: seoData.path }, }), }); } diff --git a/frontend-next-migration/src/entities/Hero/model/HeroManager.ts b/frontend-next-migration/src/entities/Hero/model/HeroManager.ts index a9700d369..69f8c06f9 100644 --- a/frontend-next-migration/src/entities/Hero/model/HeroManager.ts +++ b/frontend-next-migration/src/entities/Hero/model/HeroManager.ts @@ -1,11 +1,13 @@ import { GroupInfo, HeroWithGroup, HeroGroup, HeroSlug } from '../types/hero'; // import { HeroLevel, HeroStats } from '../types/HeroStats'; -import { initializeHeroGroups } from './initializeHeroGroups'; +import { initializeHeroGroups, initializeHeroGroupsFromDirectus } from './initializeHeroGroups'; // import { HeroStatsManager } from './stats'; +import { fetchHeroBySlug, fetchAllHeroes, type Locale } from './heroApi'; export class HeroManager { private readonly t: (key: string) => string; - private readonly heroGroups: Record; + private heroGroups: Record; + private heroesCache: HeroWithGroup[] | null = null; // private heroStatsManager: HeroStatsManager; constructor(t: (key: string) => string) { @@ -14,12 +16,36 @@ export class HeroManager { // this.heroStatsManager = new HeroStatsManager(); } + /** + * Initialize hero groups from Directus (async) + */ + public async initializeFromDirectus(locale: Locale = 'en'): Promise { + try { + const directusGroups = await initializeHeroGroupsFromDirectus(locale); + // Only replace static data if Directus returned non-empty groups + if (directusGroups && Object.keys(directusGroups).length > 0) { + this.heroGroups = directusGroups; + this.heroesCache = null; // Clear cache to force recalculation + } else { + console.warn('[HeroManager] Directus returned empty groups, keeping static data'); + } + } catch (error) { + console.error('[HeroManager] Failed to initialize hero groups from Directus:', error); + // Keep existing static data as fallback + } + } + // public getHeroStatsBySlugAndLevel(slug: HeroSlug, statLevel: HeroLevel): HeroStats { // return this.heroStatsManager.getStatsForHero(slug, statLevel); // } public getAllHeroes(): HeroWithGroup[] { - return Object.entries(this.heroGroups).flatMap(([group, groupInfo]) => { + // Use cache if available + if (this.heroesCache) { + return this.heroesCache; + } + + const heroes = Object.entries(this.heroGroups).flatMap(([group, groupInfo]) => { const { name: groupName, description: groupDescription, @@ -35,6 +61,30 @@ export class HeroManager { groupBgColour, })); }); + + this.heroesCache = heroes; + return heroes; + } + + /** + * Get all heroes from Directus (async) + */ + public async getAllHeroesFromDirectus(locale: Locale = 'en'): Promise { + try { + const heroes = await fetchAllHeroes(locale); + // If Directus returns empty array, fallback to static data + if (heroes.length === 0) { + console.warn( + '[HeroManager] Directus returned empty heroes array, using static data', + ); + return this.getAllHeroes(); + } + return heroes; + } catch (error) { + console.error('[HeroManager] Failed to fetch all heroes from Directus:', error); + // Fallback to static data + return this.getAllHeroes(); + } } public getGroupsWithHeroes(): Record { @@ -55,6 +105,23 @@ export class HeroManager { return this.getAllHeroes().find((hero) => hero.slug === slug); } + /** + * Get hero by slug from Directus (async) + * Falls back to static data if Directus fetch fails + */ + public async getHeroBySlugAsync( + slug: HeroSlug, + locale: Locale = 'en', + ): Promise { + try { + const hero = await fetchHeroBySlug(slug, locale); + if (hero) return hero; + } catch { + // ignore error and fallback + } + return this.getHeroBySlug(slug); + } + public getHeroesBySpecificGroup(group: HeroGroup): HeroWithGroup[] | undefined { const groupInfo = this.heroGroups[group]; if (!groupInfo) return undefined; diff --git a/frontend-next-migration/src/entities/Hero/model/buildHeroQueryParams.ts b/frontend-next-migration/src/entities/Hero/model/buildHeroQueryParams.ts new file mode 100644 index 000000000..c1504b3fc --- /dev/null +++ b/frontend-next-migration/src/entities/Hero/model/buildHeroQueryParams.ts @@ -0,0 +1,80 @@ +import type { Locale } from './heroApi'; + +/** Relation field keys (O2M translations) */ +const HERO_TR_KEYS = ['translations'] as const; +const GROUP_TR_KEYS = ['translations'] as const; + +/** File field keys */ +const HERO_IMG_KEYS = ['srcImg'] as const; +const HERO_GIF_KEYS = ['srcGif'] as const; +const GROUP_IMG_KEYS = ['srcImg'] as const; +const GROUP_LABEL_KEYS = ['label'] as const; + +/** Stats relation key */ +const STATS_KEYS = ['stats'] as const; + +/** Normalized languages_code in Directus */ +const languageCode = (locale: Locale): string => locale; + +/** Fields requested from Directus */ +export const FIELDS = [ + 'id', + 'slug', + 'order', + ...HERO_IMG_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), + ...HERO_GIF_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), + ...HERO_TR_KEYS.flatMap((key) => [ + `${key}.languages_code`, + `${key}.title`, + `${key}.description`, + `${key}.alt`, + `${key}.altGif`, + ]), + 'rarityClass', + 'group.id', + 'group.key', + 'group.bgColour', + ...GROUP_IMG_KEYS.flatMap((key) => [ + `group.${key}.id`, + `group.${key}.width`, + `group.${key}.height`, + ]), + ...GROUP_LABEL_KEYS.flatMap((key) => [ + `group.${key}.id`, + `group.${key}.width`, + `group.${key}.height`, + ]), + ...GROUP_TR_KEYS.flatMap((key) => [ + `group.${key}.languages_code`, + `group.${key}.name`, + `group.${key}.description`, + ]), + ...STATS_KEYS.flatMap((key) => [ + `${key}.name`, + `${key}.rarityClass`, + `${key}.defaultLevel`, + `${key}.developmentLevel`, + `${key}.order`, + ]), +].join(','); + +/** + * Build query parameters for Directus hero API calls + */ +export function buildHeroQueryParams( + locale: Locale, + options?: { slug?: string; limit?: string }, +): URLSearchParams { + const params = new URLSearchParams(); + if (options?.slug) params.set('filter[slug][_eq]', options.slug); + if (options?.limit) params.set('limit', options.limit); + params.set('fields', FIELDS); + params.set('sort', 'order'); + for (const key of HERO_TR_KEYS) { + params.set(`deep[${key}][filter][languages_code][_eq]`, languageCode(locale)); + } + for (const key of GROUP_TR_KEYS) { + params.set(`deep[group][${key}][filter][languages_code][_eq]`, languageCode(locale)); + } + return params; +} diff --git a/frontend-next-migration/src/entities/Hero/model/groupHeroesByGroup.ts b/frontend-next-migration/src/entities/Hero/model/groupHeroesByGroup.ts new file mode 100644 index 000000000..c4d1ec83f --- /dev/null +++ b/frontend-next-migration/src/entities/Hero/model/groupHeroesByGroup.ts @@ -0,0 +1,44 @@ +import { HeroWithGroup, HeroGroup, GroupInfo } from '../types/hero'; + +/** Helper to group heroes by their groupEnum */ +export function groupHeroesByGroup(heroes: HeroWithGroup[]): Record { + const groupsMap = new Map(); + + for (const hero of heroes) { + const groupKey = hero.groupEnum; + + if (!groupsMap.has(groupKey)) { + groupsMap.set(groupKey, { + name: hero.groupName, + description: hero.groupDescription, + bgColour: hero.groupBgColour, + srcImg: typeof hero.groupLabel === 'string' ? '' : hero.groupLabel || '', + label: hero.groupLabel || '', + heroes: [], + }); + } + + const group = groupsMap.get(groupKey); + if (group) { + group.heroes.push({ + id: hero.id, + slug: hero.slug, + srcImg: hero.srcImg, + srcGif: hero.srcGif, + alt: hero.alt, + altGif: hero.altGif, + title: hero.title, + rarityClass: hero.rarityClass || '', + description: hero.description, + stats: hero.stats, + }); + } + } + + const result = {} as Record; + Array.from(groupsMap.entries()).forEach(([key, value]) => { + result[key] = value; + }); + + return result; +} diff --git a/frontend-next-migration/src/entities/Hero/model/heroApi.ts b/frontend-next-migration/src/entities/Hero/model/heroApi.ts new file mode 100644 index 000000000..c541e3cd4 --- /dev/null +++ b/frontend-next-migration/src/entities/Hero/model/heroApi.ts @@ -0,0 +1,331 @@ +import { directusApi } from '@/shared/api/directusApi'; +import { envHelper } from '@/shared/const/envHelper'; +import type { + GroupInfo, + HeroGroup, + HeroSlug, + HeroWithGroup, + Stat, +} from '@/entities/Hero/types/hero'; +import type { StaticImageData } from 'next/image'; +import { groupHeroesByGroup } from './groupHeroesByGroup'; + +export type Locale = 'en' | 'fi' | 'ru'; + +const HERO_TR_KEYS = ['translations'] as const; +const GROUP_TR_KEYS = ['translations'] as const; +const HERO_IMG_KEYS = ['srcImg'] as const; +const HERO_GIF_KEYS = ['srcGif'] as const; +const GROUP_IMG_KEYS = ['srcImg'] as const; +const GROUP_LABEL_KEYS = ['label'] as const; +// Directus can expose the O2M from heroes -> heroes_stats as `heroes_stats` (common), +// while older/static mapping used `stats`. Support both. +const STATS_KEYS = ['heroes_stats', 'stats'] as const; + +const ASSET = (id?: string) => + id ? `${(envHelper.directusHost || '').replace(/\/$/, '')}/assets/${id}` : ''; +/** Normalize locale code to Directus format (fi -> fi-FI, en -> en-US, ru -> ru-RU) */ +const normalizeLocale = (locale: Locale): string => { + const localeMap: Record = { + fi: 'fi-FI', + en: 'en-US', + ru: 'ru-RU', + }; + return localeMap[locale] || locale; +}; +const languageCode = (locale: Locale): string => normalizeLocale(locale); + +function pickTranslationByLocale( + arr: T[] | undefined, + locale: Locale, +): T | undefined { + if (!arr?.length) return undefined; + return arr.find((t) => t?.languages_code === languageCode(locale)) ?? arr[0]; +} + +const FIELDS = [ + 'id', + 'slug', + 'order', + ...HERO_IMG_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), + ...HERO_GIF_KEYS.flatMap((key) => [`${key}.id`, `${key}.width`, `${key}.height`]), + ...HERO_TR_KEYS.flatMap((key) => [ + `${key}.languages_code`, + `${key}.title`, + `${key}.description`, + `${key}.alt`, + `${key}.altGif`, + ]), + 'rarityClass', + 'group.id', + 'group.key', + 'group.bgColour', + ...GROUP_IMG_KEYS.flatMap((key) => [ + `group.${key}.id`, + `group.${key}.width`, + `group.${key}.height`, + ]), + ...GROUP_LABEL_KEYS.flatMap((key) => [ + `group.${key}.id`, + `group.${key}.width`, + `group.${key}.height`, + ]), + ...GROUP_TR_KEYS.flatMap((key) => [ + `group.${key}.languages_code`, + `group.${key}.name`, + `group.${key}.description`, + ]), + ...STATS_KEYS.flatMap((key) => [ + `${key}.name`, + `${key}.defaultLevel`, + `${key}.developmentLevel`, + `${key}.order`, + ]), +].join(','); + +const firstKey = (obj: any, keys: readonly string[]) => + keys.find((key) => key && obj?.[key] !== undefined); +const firstArray = (obj: any, keys: readonly string[]) => + (keys + .filter(Boolean) + .map((key) => obj?.[key]) + .find((value) => Array.isArray(value)) as any[]) || []; +const mkStaticImage = (id?: string, width?: number, height?: number): StaticImageData | '' => + id + ? ({ src: ASSET(id), width: width || 1, height: height || 1 } as unknown as StaticImageData) + : ''; +const mapRarityClass = (val: number | string | undefined): string => + typeof val === 'number' ? ({ 0: 'common', 1: 'rare', 2: 'epic' }[val] ?? '') : (val ?? ''); +// eslint-disable-next-line complexity +function mapHero(item: any, locale: Locale): HeroWithGroup { + // translations + const heroTranslationsArr = firstArray(item, HERO_TR_KEYS); + const groupTranslationsArr = firstArray(item?.group ?? {}, GROUP_TR_KEYS); + + const heroTr = pickTranslationByLocale(heroTranslationsArr, locale) ?? {}; + const groupTr = pickTranslationByLocale(groupTranslationsArr, locale) ?? {}; + + // hero images + const heroImgKey = firstKey(item, HERO_IMG_KEYS); + const heroGifKey = firstKey(item, HERO_GIF_KEYS); + const heroImgMeta = heroImgKey ? item?.[heroImgKey] : undefined; + const heroGifMeta = heroGifKey ? item?.[heroGifKey] : undefined; + + const srcImg = mkStaticImage(heroImgMeta?.id, heroImgMeta?.width, heroImgMeta?.height); + const srcGif = mkStaticImage(heroGifMeta?.id, heroGifMeta?.width, heroGifMeta?.height); + + // group images + const groupImgKey = firstKey(item?.group ?? {}, GROUP_IMG_KEYS); + const groupLabelKey = firstKey(item?.group ?? {}, GROUP_LABEL_KEYS); + const groupImgMeta = groupImgKey ? item?.group?.[groupImgKey] : undefined; + const groupLabelMeta = groupLabelKey ? item?.group?.[groupLabelKey] : undefined; + + const groupSrcImg = mkStaticImage(groupImgMeta?.id, groupImgMeta?.width, groupImgMeta?.height); + const groupLabel = mkStaticImage( + groupLabelMeta?.id, + groupLabelMeta?.width, + groupLabelMeta?.height, + ); + + // stats + // Prefer Directus O2M `heroes_stats` when present; fallback to `stats` + const statsArr = + (item?.heroes_stats && Array.isArray(item.heroes_stats) ? item.heroes_stats : null) ?? + firstArray(item, STATS_KEYS); + const stats: Stat[] = (statsArr || []) + .map((statItem: any) => { + const name = statItem?.name; + const defaultLevel = statItem?.defaultLevel; + const developmentLevel = statItem?.developmentLevel ?? undefined; + const order = statItem?.order ?? 0; + return { name, rarityClass: 0, defaultLevel, developmentLevel, order }; + }) + .filter((stat: any) => !!stat?.name) + .sort((a: any, b: any) => (a?.order ?? 0) - (b?.order ?? 0)) + .map(({ order: _order, ...rest }: any) => rest as Stat); + + const group: GroupInfo = { + name: groupTr?.name ?? item?.group?.key ?? 'UNKNOWN', + description: groupTr?.description ?? '', + bgColour: item?.group?.bgColour ?? '#000', + srcImg: groupSrcImg, + label: groupLabel, + heroes: [], + }; + + return { + id: item.id, + slug: item.slug as HeroSlug, + srcImg, + srcGif, + alt: heroTr?.alt ?? '', + altGif: heroTr?.altGif ?? '', + title: heroTr?.title ?? item.slug, + description: heroTr?.description ?? '', + rarityClass: mapRarityClass(item?.rarityClass), + stats, + groupEnum: (item?.group?.key as HeroGroup) ?? 'RETROFLECTOR', + groupName: group.name, + groupDescription: group.description, + groupBgColour: group.bgColour, + groupLabel: group.label, + }; +} + +function buildParams(locale: Locale, options?: { slug?: string; limit?: string }) { + const params = new URLSearchParams(); + if (options?.slug) params.set('filter[slug][_eq]', options.slug); + if (options?.limit) params.set('limit', options.limit); + params.set('fields', FIELDS); + params.set('sort', 'order'); + for (const key of HERO_TR_KEYS) { + params.set(`deep[${key}][filter][languages_code][_eq]`, languageCode(locale)); + } + for (const key of GROUP_TR_KEYS) { + params.set(`deep[group][${key}][filter][languages_code][_eq]`, languageCode(locale)); + } + return params; +} + +export const heroApi = directusApi.injectEndpoints({ + endpoints: (build) => ({ + getHeroBySlug: build.query({ + query: ({ slug, locale = 'en' }) => ({ + url: `/items/heroes?${buildParams(locale, { slug, limit: '1' }).toString()}`, + }), + transformResponse: (resp: any, _meta, args) => { + const item = resp?.data?.[0]; + return item ? mapHero(item, (args?.locale ?? 'en') as Locale) : undefined; + }, + providesTags: (_res, _err, args) => [{ type: 'Hero' as const, id: args.slug }], + }), + getHeroStatsByHeroId: build.query({ + query: ({ heroId }) => ({ + url: `/items/heroes_stats?filter[hero][_eq]=${heroId}&sort=order&fields=name,rarityClass,defaultLevel,developmentLevel,order`, + }), + transformResponse: (resp: any) => + (resp?.data || []).map((row: any) => ({ + name: row?.name, + rarityClass: row?.rarityClass, + defaultLevel: row?.defaultLevel, + developmentLevel: row?.developmentLevel ?? undefined, + })), + }), + getAllHeroes: build.query({ + query: ({ locale = 'en' }) => ({ + url: `/items/heroes?${buildParams(locale, { limit: '-1' }).toString()}`, + }), + transformResponse: (resp: any, _meta, args) => { + const items = resp?.data || []; + return items.map((item: any) => mapHero(item, (args?.locale ?? 'en') as Locale)); + }, + providesTags: () => [{ type: 'Hero' as const, id: 'LIST' }], + }), + getHeroGroups: build.query, { locale?: Locale }>({ + query: ({ locale = 'en' }) => ({ + url: `/items/heroes?${buildParams(locale, { limit: '-1' }).toString()}`, + }), + transformResponse: (resp: any, _meta, args) => { + const items = resp?.data || []; + const heroes = items.map((item: any) => + mapHero(item, (args?.locale ?? 'en') as Locale), + ); + return groupHeroesByGroup(heroes); + }, + providesTags: () => [{ type: 'Hero' as const, id: 'GROUPS' }], + }), + }), +}); + +export const { + useGetHeroBySlugQuery, + useGetHeroStatsByHeroIdQuery, + useGetAllHeroesQuery, + useGetHeroGroupsQuery, +} = heroApi; + +export async function fetchHeroBySlug( + slug: HeroSlug, + locale: Locale = 'en', +): Promise { + const base = (envHelper.directusHost || '').replace(/\/$/, ''); + if (!base) { + // eslint-disable-next-line no-console + console.warn( + '[fetchHeroBySlug] Directus host not configured - check NEXT_PUBLIC_DIRECTUS_HOST env var', + ); + return undefined; + } + + const params = buildParams(locale, { slug, limit: '1' }); + + try { + const url = `${base}/items/heroes?${params.toString()}`; + const res = await fetch(url, { next: { revalidate: 60 } }); + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + // eslint-disable-next-line no-console + console.warn( + `[fetchHeroBySlug] Directus error ${res.status} for "${slug}": ${errorText.substring(0, 200)}`, + ); + return undefined; + } + + const json = await res.json(); + const item = json?.data?.[0]; + if (!item) { + // eslint-disable-next-line no-console + console.warn( + `[fetchHeroBySlug] No hero found in Directus for slug "${slug}" (response had ${json?.data?.length || 0} items)`, + ); + return undefined; + } + + return mapHero(item, locale); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + `[fetchHeroBySlug] ✗ Error fetching "${slug}":`, + error instanceof Error ? error.message : error, + ); + return undefined; + } +} + +export async function fetchAllHeroes(locale: Locale = 'en'): Promise { + const base = (envHelper.directusHost || '').replace(/\/$/, ''); + if (!base) { + // eslint-disable-next-line no-console + console.warn( + '[fetchAllHeroes] Directus host not configured - check NEXT_PUBLIC_DIRECTUS_HOST env var', + ); + return []; + } + + const params = buildParams(locale, { limit: '-1' }); + + try { + const url = `${base}/items/heroes?${params.toString()}`; + const res = await fetch(url, { next: { revalidate: 60 } }); + + if (!res.ok) { + const errorText = await res.text().catch(() => ''); + // eslint-disable-next-line no-console + console.warn( + `[fetchAllHeroes] Directus error ${res.status}: ${errorText.substring(0, 200)}`, + ); + return []; + } + + const json = await res.json(); + const items = json?.data || []; + return items.map((item: any) => mapHero(item, locale)); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + '[fetchAllHeroes] ✗ Fetch error:', + error instanceof Error ? error.message : error, + ); + return []; + } +} diff --git a/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts b/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts index 851303efa..50db6bc82 100644 --- a/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts +++ b/frontend-next-migration/src/entities/Hero/model/initializeHeroGroups.ts @@ -1,6 +1,11 @@ -import { type GroupInfo, HeroGroup, HeroSlug } from '../types/hero'; +import { type GroupInfo, HeroGroup } from '../types/hero'; import { buildHeroGroups } from '@/entities/Hero/model/heroGroupsData'; +import { fetchAllHeroes, type Locale } from './heroApi'; +import { groupHeroesByGroup } from './groupHeroesByGroup'; +/** + * Initialize hero groups from static data (legacy, for fallback) + */ export const initializeHeroGroups = (t: (key: string) => string): Record => { // Local data or overrides specific to initialize step can be defined here and merged in. const localData: Partial> = {}; @@ -10,3 +15,23 @@ export const initializeHeroGroups = (t: (key: string) => string): Record; }; + +/** + * Initialize hero groups from Directus (async) + */ +export async function initializeHeroGroupsFromDirectus( + locale: Locale = 'en', +): Promise> { + try { + const heroes = await fetchAllHeroes(locale); + return groupHeroesByGroup(heroes); + } catch (error) { + // eslint-disable-next-line no-console + console.error( + '[initializeHeroGroupsFromDirectus] Failed to fetch hero groups from Directus:', + error, + ); + // Return empty record - caller should check and keep static data + return {} as Record; + } +} diff --git a/frontend-next-migration/src/entities/Hero/model/mapHero.ts b/frontend-next-migration/src/entities/Hero/model/mapHero.ts new file mode 100644 index 000000000..676e9804c --- /dev/null +++ b/frontend-next-migration/src/entities/Hero/model/mapHero.ts @@ -0,0 +1,121 @@ +import type { HeroWithGroup, HeroGroup, HeroSlug, Stat, GroupInfo } from '../types/hero'; +import type { StaticImageData } from 'next/image'; +import { envHelper } from '@/shared/const/envHelper'; + +export type Locale = 'en' | 'fi' | 'ru'; + +/** Relation field keys (O2M translations) */ +const HERO_TR_KEYS = ['translations'] as const; +const GROUP_TR_KEYS = ['translations'] as const; + +/** File field keys */ +const HERO_IMG_KEYS = ['srcImg'] as const; +const HERO_GIF_KEYS = ['srcGif'] as const; +const GROUP_IMG_KEYS = ['srcImg'] as const; +const GROUP_LABEL_KEYS = ['label'] as const; + +/** Stats relation key */ +const STATS_KEYS = ['stats'] as const; + +/** Build a public asset URL from a Directus file ID. */ +const ASSET = (id?: string) => + id ? `${(envHelper.directusHost || '').replace(/\/$/, '')}/assets/${id}` : ''; + +/** Normalized languages_code in Directus */ +const languageCode = (locale: Locale): string => locale; + +/** Pick the matching translation; fallback to first if none match */ +function pickTranslationByLocale( + arr: T[] | undefined, + locale: Locale, +): T | undefined { + if (!arr?.length) return undefined; + return arr.find((t) => t?.languages_code === languageCode(locale)) ?? arr[0]; +} + +/** Utility helpers */ +const firstKey = (obj: any, keys: readonly string[]) => + keys.find((key) => key && obj?.[key] !== undefined); + +const firstArray = (obj: any, keys: readonly string[]) => + (keys + .filter(Boolean) + .map((key) => obj?.[key]) + .find((value) => Array.isArray(value)) as any[]) || []; + +const mkStaticImage = (id?: string, width?: number, height?: number): StaticImageData | '' => + id + ? ({ src: ASSET(id), width: width || 1, height: height || 1 } as unknown as StaticImageData) + : ''; + +/** Mapper */ +// eslint-disable-next-line complexity +export function mapHero(item: any, locale: Locale): HeroWithGroup { + // translations + const heroTranslationsArr = firstArray(item, HERO_TR_KEYS); + const groupTranslationsArr = firstArray(item?.group ?? {}, GROUP_TR_KEYS); + + const heroTr = pickTranslationByLocale(heroTranslationsArr, locale) ?? {}; + const groupTr = pickTranslationByLocale(groupTranslationsArr, locale) ?? {}; + + // hero images + const heroImgKey = firstKey(item, HERO_IMG_KEYS); + const heroGifKey = firstKey(item, HERO_GIF_KEYS); + const heroImgMeta = heroImgKey ? item?.[heroImgKey] : undefined; + const heroGifMeta = heroGifKey ? item?.[heroGifKey] : undefined; + + const srcImg = mkStaticImage(heroImgMeta?.id, heroImgMeta?.width, heroImgMeta?.height); + const srcGif = mkStaticImage(heroGifMeta?.id, heroGifMeta?.width, heroGifMeta?.height); + + // group images + const groupImgKey = firstKey(item?.group ?? {}, GROUP_IMG_KEYS); + const groupLabelKey = firstKey(item?.group ?? {}, GROUP_LABEL_KEYS); + const groupImgMeta = groupImgKey ? item?.group?.[groupImgKey] : undefined; + const groupLabelMeta = groupLabelKey ? item?.group?.[groupLabelKey] : undefined; + + const groupSrcImg = mkStaticImage(groupImgMeta?.id, groupImgMeta?.width, groupImgMeta?.height); + const groupLabel = mkStaticImage( + groupLabelMeta?.id, + groupLabelMeta?.width, + groupLabelMeta?.height, + ); + + // stats + const statsArr = firstArray(item, STATS_KEYS); + const stats: Stat[] = (statsArr || []) + .slice() + .sort((a: any, b: any) => (a?.order ?? 0) - (b?.order ?? 0)) + .map((statItem: any) => ({ + name: statItem?.name, + rarityClass: statItem?.rarityClass, + defaultLevel: statItem?.defaultLevel, + developmentLevel: statItem?.developmentLevel ?? undefined, + })); + + const group: GroupInfo = { + name: groupTr?.name ?? item?.group?.key ?? 'UNKNOWN', + description: groupTr?.description ?? '', + bgColour: item?.group?.bgColour ?? '#000', + srcImg: groupSrcImg, + label: groupLabel, + heroes: [], + }; + + return { + id: item.id, + slug: item.slug as HeroSlug, + srcImg, + srcGif, + alt: heroTr?.alt ?? '', + altGif: heroTr?.altGif ?? '', + title: heroTr?.title ?? item.slug, + description: heroTr?.description ?? '', + rarityClass: item.rarityClass || '', + stats, + groupEnum: (item?.group?.key as HeroGroup) ?? 'RETROFLECTOR', + groupName: group.name, + groupDescription: group.description, + groupBgColour: group.bgColour, + groupLabel: group.label, + }; +} diff --git a/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx b/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx index 895e44689..9a9cdc70f 100644 --- a/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx +++ b/frontend-next-migration/src/features/NavigateHeroGroups/ui/HeroGroupNavMenu/HeroGroupNavMenu.tsx @@ -1,8 +1,9 @@ 'use client'; import React from 'react'; -import { usePathname } from 'next/navigation'; +import { usePathname, useParams } from 'next/navigation'; import cls from './HeroGroupNavMenu.module.scss'; import { HeroGroup } from '@/entities/Hero'; +import { useGetHeroGroupsQuery } from '@/entities/Hero/model/heroApi'; import { useClientTranslation } from '@/shared/i18n'; import { getRouteDefenseGalleryGroupPage } from '@/shared/appLinks/RoutePaths'; import { initializeHeroGroups } from '@/entities/Hero/model/initializeHeroGroups'; @@ -18,8 +19,20 @@ interface HeroGroupNavMenuProps { const HeroGroupNavMenu: React.FC = ({ className: _className }) => { const { t } = useClientTranslation('heroes'); const pathname = usePathname(); + const params = useParams(); + const lng = (params?.lng as string) || 'en'; + const locale = (lng === 'en' ? 'en' : lng === 'fi' ? 'fi' : 'ru') as 'en' | 'fi' | 'ru'; const selectedHeroGroup = pathname.split('/')[3]; - const allHeroGroups = initializeHeroGroups(t); + + // Try to fetch from Directus first, fallback to static data + const { data: directusGroups } = useGetHeroGroupsQuery({ locale }); + const staticGroups = React.useMemo(() => initializeHeroGroups(t), [t]); + const allHeroGroups = React.useMemo(() => { + if (directusGroups && Object.keys(directusGroups).length > 0) { + return directusGroups; + } + return staticGroups; + }, [directusGroups, staticGroups]); function capitalizeString(inputString: HeroGroup | string) { if (!inputString) return ''; diff --git a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx index cd350ec8f..3c7a39649 100644 --- a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx +++ b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/DefenseGalleryPage.tsx @@ -13,15 +13,34 @@ import { SearchBar } from './SingleDefensePage'; import { useClientTranslation } from '@/shared/i18n'; import { initializeHeroGroups } from '@/entities/Hero/model/initializeHeroGroups'; import { Hero } from '@/entities/Hero'; +import { useGetHeroGroupsQuery } from '@/entities/Hero/model/heroApi'; import { MobileCard, MobileCardLink, MobileCardTheme } from '@/shared/ui/v2/MobileCard'; import { ModularCard, ModularCardTheme } from '@/shared/ui/v2/ModularCard'; import { PageTitle } from '@/shared/ui/PageTitle'; +import { useParams } from 'next/navigation'; const DefenseGalleryPage = () => { const { isMobileSize, isTabletSize } = useSizes(); const [searchQuery, setSearchQuery] = React.useState(''); const { t } = useClientTranslation('heroes'); - const heroGroups = initializeHeroGroups(t); + const params = useParams(); + const lng = (params?.lng as string) || 'en'; + const locale = (lng === 'en' ? 'en' : lng === 'fi' ? 'fi' : 'ru') as 'en' | 'fi' | 'ru'; + + // Try to fetch from Directus first, fallback to static data + const { data: directusGroups, isError, error } = useGetHeroGroupsQuery({ locale }); + const staticGroups = React.useMemo(() => initializeHeroGroups(t), [t]); + const heroGroups = React.useMemo(() => { + if (isError) { + // eslint-disable-next-line no-console + console.warn('[DefenseGalleryPage] Directus query failed, using static data:', error); + return staticGroups; + } + if (directusGroups && Object.keys(directusGroups).length > 0) { + return directusGroups; + } + return staticGroups; + }, [directusGroups, staticGroups, isError, error]); // Create an array of all heroes with their group information const allHeroesWithGroups = React.useMemo(() => { @@ -31,11 +50,12 @@ const DefenseGalleryPage = () => { groupBgColor: string; }[] = []; Object.entries(heroGroups).forEach(([_, groupInfo]) => { - groupInfo.heroes.forEach((hero) => { + const group = groupInfo as { name: string; bgColour: string; heroes: Hero[] }; + group.heroes.forEach((hero) => { result.push({ hero, - groupName: groupInfo.name, - groupBgColor: groupInfo.bgColour, + groupName: group.name, + groupBgColor: group.bgColour, }); }); }); diff --git a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx index f9c5c2add..19cfa12ba 100644 --- a/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx +++ b/frontend-next-migration/src/preparedPages/DefenseGalleryPages/ui/SingleDefensePage.tsx @@ -1,5 +1,6 @@ 'use client'; -import { HeroGroup } from '@/entities/Hero'; +import { HeroGroup, Hero } from '@/entities/Hero'; +import { useGetHeroGroupsQuery } from '@/entities/Hero/model/heroApi'; import Image from 'next/image'; import { initializeHeroGroups } from '@/entities/Hero/model/initializeHeroGroups'; import { useClientTranslation } from '@/shared/i18n'; @@ -16,15 +17,27 @@ import { MobileCard, MobileCardLink, MobileCardTheme } from '@/shared/ui/v2/Mobi import search from '@/shared/assets/icons/Search.svg'; import cls from './DefenseGalleryPage.module.scss'; import { PageTitle } from '@/shared/ui/PageTitle'; +import { useParams } from 'next/navigation'; +import type { StaticImageData } from 'next/image'; export interface Props { heroGroup: HeroGroup; } + export interface SearchBarProps { className: string; value: string; onChange: (value: string) => void; } + +type GroupInfoType = { + name: string; + description: string; + bgColour: string; + srcImg: string | StaticImageData; + heroes: Hero[]; +}; + export const SearchBar = (props: SearchBarProps) => { const { className, value, onChange } = props; return ( @@ -44,70 +57,88 @@ export const SearchBar = (props: SearchBarProps) => { ); }; -const SingleDefensePage = (props: Props) => { - const { heroGroup } = props; - const { t } = useClientTranslation('heroes'); - const heroGroups = initializeHeroGroups(t); - const { isMobileSize, isTabletSize } = useSizes(); - const [searchQuery, setSearchQuery] = React.useState(''); - const filteredHeroes = React.useMemo(() => { - if (!searchQuery.trim()) { - return heroGroups[heroGroup].heroes; - } - - const query = searchQuery.toLowerCase(); - return heroGroups[heroGroup].heroes.filter( - (hero) => - hero.title.toLowerCase().includes(query) || - heroGroups[heroGroup].name.toLowerCase().includes(query), - ); - }, [searchQuery, heroGroup, heroGroups]); +interface MobileViewProps { + heroGroup: HeroGroup; + heroGroups: Record; + filteredHeroes: Hero[]; + searchQuery: string; + setSearchQuery: (value: string) => void; +} - if (isMobileSize) - return ( -
- -
- - - {heroGroups[heroGroup].description} - - - -
-
- {filteredHeroes.map((hero, index) => ( - - - - - - - ))} -
+const MobileView = ({ + heroGroup, + heroGroups, + filteredHeroes, + searchQuery, + setSearchQuery, +}: MobileViewProps) => { + const group = heroGroups[heroGroup]; + return ( +
+ +
+ + + {group?.description || ''} + + +
- ); +
+ {filteredHeroes.map((hero: Hero, index: number) => ( + + + + + + + ))} +
+
+ ); +}; + +interface DesktopViewProps { + heroGroup: HeroGroup; + heroGroups: Record; + filteredHeroes: Hero[]; + searchQuery: string; + setSearchQuery: (value: string) => void; + isTabletSize: boolean; + t: (key: string) => string; +} + +const DesktopView = ({ + heroGroup, + heroGroups, + filteredHeroes, + searchQuery, + setSearchQuery, + isTabletSize, + t, +}: DesktopViewProps) => { + const group = heroGroups[heroGroup]; return (
{isTabletSize ? ( @@ -132,25 +163,23 @@ const SingleDefensePage = (props: Props) => { )} - - {heroGroups[heroGroup].name} - + {group?.name || ''} - {heroGroups[heroGroup].description} + {group?.description || ''} - +
- {filteredHeroes.map((hero, index) => ( + {filteredHeroes.map((hero: Hero, index: number) => (
{ > - {heroGroups[heroGroup].name} + {group?.name || ''} {hero.title} @@ -187,4 +216,86 @@ const SingleDefensePage = (props: Props) => { ); }; +function getLocaleFromParams(params: ReturnType): 'en' | 'fi' | 'ru' { + const lng = (params?.lng as string) || 'en'; + if (lng === 'en') return 'en'; + if (lng === 'fi') return 'fi'; + return 'ru'; +} + +function useHeroGroupsWithFallback( + locale: 'en' | 'fi' | 'ru', + t: (key: string) => string, +): Record { + const { data: directusGroups, isError, error } = useGetHeroGroupsQuery({ locale }); + const staticGroups = React.useMemo(() => initializeHeroGroups(t), [t]); + // Use Directus data only if it exists, has keys, and there's no error + const hasDirectusData = !isError && directusGroups && Object.keys(directusGroups).length > 0; + if (isError) { + // eslint-disable-next-line no-console + console.warn( + '[useHeroGroupsWithFallback] Directus query failed, using static data:', + error, + ); + } + return React.useMemo( + () => (hasDirectusData ? directusGroups : staticGroups), + [hasDirectusData, directusGroups, staticGroups], + ); +} + +function filterHeroesByQuery( + heroGroups: Record, + heroGroup: HeroGroup, + searchQuery: string, +): Hero[] { + const group = heroGroups[heroGroup]; + if (!group) return []; + if (!searchQuery.trim()) return group.heroes; + + const query = searchQuery.toLowerCase(); + const matchesQuery = (hero: Hero) => + hero.title.toLowerCase().includes(query) || group.name.toLowerCase().includes(query); + return group.heroes.filter(matchesQuery); +} + +const SingleDefensePage = (props: Props) => { + const { heroGroup } = props; + const { t } = useClientTranslation('heroes'); + const params = useParams(); + const locale = getLocaleFromParams(params); + const heroGroups = useHeroGroupsWithFallback(locale, t); + const { isMobileSize, isTabletSize } = useSizes(); + const [searchQuery, setSearchQuery] = React.useState(''); + + const filteredHeroes = React.useMemo( + () => filterHeroesByQuery(heroGroups, heroGroup, searchQuery), + [searchQuery, heroGroup, heroGroups], + ); + + if (isMobileSize) { + return ( + + ); + } + + return ( + + ); +}; + export default SingleDefensePage; diff --git a/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx b/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx index 0389c5f06..35ce027cc 100644 --- a/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx +++ b/frontend-next-migration/src/preparedPages/HeroesPages/ui/SingleHeroPage.tsx @@ -10,7 +10,7 @@ * are clamped with a "Read more" toggle. * - On desktop/tablet, headings and layout are two-column. */ -import { HeroSlug, Hero } from '@/entities/Hero'; +import { HeroSlug, Hero, HeroWithGroup } from '@/entities/Hero'; import Image from 'next/image'; import { initializeHeroGroups } from '@/entities/Hero/model/initializeHeroGroups'; import { useClientTranslation } from '@/shared/i18n'; @@ -21,6 +21,8 @@ import useSizes from '@/shared/lib/hooks/useSizes'; import cls from './SingleHeroPage.module.scss'; import { PageTitle } from '@/shared/ui/PageTitle'; import { BarIndicator } from '@/shared/ui/v2/BarIndicator'; +import { useGetHeroBySlugQuery, useGetHeroStatsByHeroIdQuery } from '@/entities/Hero/model/heroApi'; +import { useParams } from 'next/navigation'; /** * Props for `SingleHeroPage` component. @@ -30,6 +32,8 @@ import { BarIndicator } from '@/shared/ui/v2/BarIndicator'; export interface Props { /** Hero identifier (URL slug) used to select a hero from initialized groups */ slug: HeroSlug; + /** Hero data from server (optional, will fetch from Directus if not provided) */ + newSelectedHero?: HeroWithGroup; } /** @@ -45,11 +49,23 @@ export interface Props { * @returns JSX element that forms the page content */ const SingleHeroPage = (props: Props) => { - const { slug } = props; + const { slug, newSelectedHero } = props; const { t } = useClientTranslation('heroes'); - const heroGroups = initializeHeroGroups(t); + const params = useParams(); + const locale = (params?.lng as 'en' | 'fi' | 'ru') || 'en'; const { isMobileSize, isTabletSize } = useSizes(); + // Try to fetch from Directus if hero not provided from server + const { data: directusHero } = useGetHeroBySlugQuery( + { slug, locale }, + { skip: !!newSelectedHero }, + ); + const selectedHeroForStats = newSelectedHero || directusHero; + const { data: directusStats } = useGetHeroStatsByHeroIdQuery( + { heroId: selectedHeroForStats?.id as number }, + { skip: !selectedHeroForStats?.id }, + ); + // Mobile-only: state for clamping description text const descRef = useRef(null); const [isExpanded, setIsExpanded] = useState(false); @@ -57,19 +73,62 @@ const SingleHeroPage = (props: Props) => { // Determine selected hero and its group to populate localized titles const { titleText, hero } = useMemo<{ titleText: string; hero?: Hero }>(() => { - // Find hero by slug across all groups + // Priority: 1. Server-provided hero, 2. Directus hero, 3. Static data fallback + const selectedHero = newSelectedHero || directusHero; + if (selectedHero) { + // Merge Directus stats (levels) with baseline tiers (rarityClass) from the hero data. + // Directus `heroes_stats` rows may not contain `rarityClass`, but UI needs it for the + // "master/expert/..." label. + const mergedStats = + directusStats && directusStats.length > 0 + ? directusStats.map((row) => { + const baseline = selectedHero.stats.find( + (stat) => stat.name === row.name, + ); + return { + ...row, + // Prefer Directus rarityClass when present; fallback to baseline (static) tier. + rarityClass: row.rarityClass ?? baseline?.rarityClass ?? 10, + }; + }) + : selectedHero.stats; + return { + titleText: selectedHero.groupName, + hero: { + id: selectedHero.id, + slug: selectedHero.slug, + srcImg: selectedHero.srcImg, + srcGif: selectedHero.srcGif, + alt: selectedHero.alt, + altGif: selectedHero.altGif, + title: selectedHero.title, + rarityClass: selectedHero.rarityClass || '', + description: selectedHero.description, + stats: mergedStats, + }, + }; + } + + // Fallback to static data + const heroGroups = initializeHeroGroups(t); for (const groupKey in heroGroups) { const group = heroGroups[groupKey as keyof typeof heroGroups]; const hero = group.heroes.find((hero) => hero.slug === slug); if (hero) { return { - titleText: group.name, // already localized via initializeHeroGroups(t) + titleText: group.name, hero, }; } } return { titleText: '', hero: undefined }; - }, [heroGroups, slug]); + }, [newSelectedHero, directusHero, directusStats, slug, t]); + + const rarityLabel = useMemo(() => { + if (!hero?.rarityClass) return ''; + const key = hero.rarityClass.toLowerCase(); + return key === 'common' || key === 'rare' || key === 'epic' ? t(key) : hero.rarityClass; + }, [hero?.rarityClass, t]); /** * Converts a hero's skill level (`rarityClass`/value) to an i18n key. @@ -139,7 +198,7 @@ const SingleHeroPage = (props: Props) => {

{t('character-introduction')}

{t('rarity')}:  - {hero.rarityClass} + {rarityLabel}
{

{t('character-introduction')}

{t('rarity')}:  - {hero.rarityClass} + {rarityLabel}
{hero.description}
diff --git a/frontend-next-migration/src/shared/api/directusApi.ts b/frontend-next-migration/src/shared/api/directusApi.ts index e141fb14e..822914307 100644 --- a/frontend-next-migration/src/shared/api/directusApi.ts +++ b/frontend-next-migration/src/shared/api/directusApi.ts @@ -17,5 +17,6 @@ export const directusApi = createApi({ return headers; }, }), + tagTypes: ['Hero'], endpoints: () => ({}), }); diff --git a/frontend-next-migration/src/shared/assets/images/heros/einstein/Professor_min.gif b/frontend-next-migration/src/shared/assets/images/heros/einstein/Professor_min.gif new file mode 100644 index 000000000..c8c61aae7 Binary files /dev/null and b/frontend-next-migration/src/shared/assets/images/heros/einstein/Professor_min.gif differ diff --git a/frontend-next-migration/src/shared/i18n/locales/ru/heroes.json b/frontend-next-migration/src/shared/i18n/locales/ru/heroes.json index 8aa341fe0..b8d179541 100644 --- a/frontend-next-migration/src/shared/i18n/locales/ru/heroes.json +++ b/frontend-next-migration/src/shared/i18n/locales/ru/heroes.json @@ -9,6 +9,9 @@ "not-found-check-heroes": "Проверить доступных героев", "section-title": "Герои", + "common": "Обычный", + "rare": "Редкий", + "epic": "Эпический", "RETROFLECTOR": { "name": "ОТРАЖАТЕЛИ", diff --git a/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts b/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts index c5db698c9..13584ff0f 100644 --- a/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts +++ b/frontend-next-migration/src/widgets/Navbar/model/data/dropdowns.ts @@ -7,6 +7,7 @@ import { getRouteGameArtPage, getRouteDefenseGalleryPage, getRouteAllClanSearchPage, + //getRouteAllFurnitureSetsPage, getRouteAllCollectionsPage, getRouteJoinUsPage, getRouteAboutPage,