feat(TEAMMSBMOB-25304): интеграция дпп со сторисами

This commit is contained in:
Онуфрийчук Егор
2026-04-10 12:31:17 +03:00
parent 173d0a858f
commit 5ce95b3a90
28 changed files with 534 additions and 188 deletions
+2 -2
View File
@@ -1,4 +1,4 @@
NODE_ENV="development"
ENABLE_MSW=true
CDN_ENDPOINT="https://mobmsb.cdn.gpb.ru"
STATIC_CONTENT_ENDPOINT="https://static.online.gpb.ru"
CDN_ENDPOINT="http://localhost:8000/msb-host"
STATIC_CONTENT_ENDPOINT="http://localhost:8000/msb-host"
+1
View File
@@ -95,6 +95,7 @@ enum FEATURE_TOGGLE_NAMES {
/** Тогл для отображения пункта меню добавления нового КА, редактирования и удаления. */
COUNTER_PARTY_IBMSB = 'counterPartyIBMSB',
PRODUCT_FOR_YOU = 'productForYouIBMSB',
STORIES = 'mainpageStoriesDisplayIBMSB',
}
export { FEATURE_TOGGLE_NAMES, type FeatureToggleData, type FeatureToggleItem, type FeatureToggleResponse };
+1 -1
View File
@@ -115,7 +115,7 @@ interface PromoTab {
interface Promo {
title?: string;
subtitle?: string;
subTitle?: string;
tag?: string;
promoUrlCDN?: string;
promoUrlStatic?: string;
@@ -1,3 +1,3 @@
const BUTTON_YM_CODE_SEPARATOR = '_';
const CODE_SEPARATOR = '_';
export {BUTTON_YM_CODE_SEPARATOR};
export { CODE_SEPARATOR };
@@ -1,5 +1,5 @@
import type { TYPE_BANNER } from '../../model';
import { BUTTON_YM_CODE_SEPARATOR } from '../constants';
import { CODE_SEPARATOR } from '../constants';
import type { DynamicBanner, SpaceWithBanners, GeneralBannerFile } from '../model';
/**
@@ -15,8 +15,8 @@ const mapGeneralBannersFile = (
.sort((prevBanner, currentBanner) => (prevBanner?.priority || 0) - (currentBanner?.priority || 0))
.forEach(({ name, spaceCode, typeBanner, description, toggles, banner: bannerInfo }) => {
const bannerInSpace = clonedSpaces?.[spaceCode]?.[typeBanner];
const ymElementName = `${typeBanner}_${name}`;
const formatName = name.toLowerCase().trim().replace(/\s/g, CODE_SEPARATOR);
const ymElementName = `${typeBanner}_${formatName}`;
const { miniature } = bannerInfo || {};
@@ -34,7 +34,8 @@ const mapGeneralBannersFile = (
if (bannerInSpace && bannerInSpace.length < bannerLimits[typeBanner]) {
const formattedBanner: DynamicBanner = {
id: `${name}_${bannerInfo?.title || ''}`,
id: formatName,
name: formatName,
typeBanner,
description,
toggles,
@@ -43,7 +44,8 @@ const mapGeneralBannersFile = (
ymElementName,
ymSpaceCode: `${spaceCode}_${typeBanner}`,
title: bannerInfo.title || '',
subtitle: bannerInfo?.subtitle,
subTitle: bannerInfo?.subTitle,
description: bannerInfo?.description,
gradient: bannerInfo?.gradient,
image: isToggleEnabled
? `${process.env.CDN_ENDPOINT}${bannerInfo.imageUrl}`
@@ -61,7 +63,7 @@ const mapGeneralBannersFile = (
? `${process.env.CDN_ENDPOINT}${buttonIconUrl}`
: `${process.env.STATIC_CONTENT_ENDPOINT}${buttonIconUrl}`,
buttonLink,
ymElementName: buttonText.trim().replace(/\s/g, BUTTON_YM_CODE_SEPARATOR),
ymElementName: buttonText.trim().replace(/\s/g, CODE_SEPARATOR),
ymSpaceCode: ymElementName,
})),
tabs: bannerInfo?.tabs,
@@ -1,5 +1,5 @@
import type { TYPE_BANNER } from '../../model';
import { BUTTON_YM_CODE_SEPARATOR } from '../constants';
import { CODE_SEPARATOR } from '../constants';
import type { DynamicBanner, SpaceWithBanners, TargetBannersResponseDto, TargetBannerFile } from '../model';
/**
@@ -56,7 +56,7 @@ const mapTargetBannersFile = (
ymElementName,
ymSpaceCode: `${spaceCode}_${typeBanner}`,
title: bannerInFounded.title || '',
subtitle: bannerInFounded?.subtitle,
subTitle: bannerInFounded?.subTitle,
gradient: bannerInFounded?.gradient,
image: isToggleEnabled
? `${process.env.CDN_ENDPOINT}${bannerInFounded.imageUrl}`
@@ -76,7 +76,7 @@ const mapTargetBannersFile = (
buttonLink,
isEco: isECO,
ymSpaceCode: ymElementName,
ymElementName: buttonText.trim().replace(/\s/g, BUTTON_YM_CODE_SEPARATOR),
ymElementName: buttonText.trim().replace(/\s/g, CODE_SEPARATOR),
})
),
tabs: bannerInFounded?.tabs,
@@ -13,7 +13,8 @@ interface Banner {
miniature?: Miniature;
isGift?: boolean;
title?: string;
subtitle?: string;
subTitle?: string;
description?: string;
gradient: DYNAMIC_BANNERS_SALE_GRADIENTS;
/** Используется для основного блока баннера. */
imageUrl?: string;
@@ -13,7 +13,7 @@ interface BannerFile {
miniature?: Miniature;
isGift?: boolean;
title?: string;
subtitle?: string;
subTitle?: string;
gradient: DYNAMIC_BANNERS_SALE_GRADIENTS;
/** Используется для основного блока баннера. */
imageUrl?: string;
+16 -12
View File
@@ -2,8 +2,20 @@ import type { DYNAMIC_BANNERS_SALE_GRADIENTS } from '@msb/shared';
import type { FEATURE_TOGGLE_NAMES } from '../../feature-toggles';
import type { AdvInfo, BANNER_BUTTON_TYPE, BANNER_SPACE_CODE, BannerTab, TYPE_BANNER } from '../../model';
interface DynamicBannerButton {
buttonType: BANNER_BUTTON_TYPE;
buttonOrder: number;
buttonText: string;
buttonLink?: string;
isEco?: boolean;
buttonIcon?: string;
ymSpaceCode: string;
ymElementName: string;
}
interface DynamicBanner {
id: string;
name?: string;
typeBanner: TYPE_BANNER;
description?: string;
toggles?: FEATURE_TOGGLE_NAMES[];
@@ -16,27 +28,19 @@ interface DynamicBanner {
ymSpaceCode: string;
ymElementName: string;
title: string;
subtitle?: string;
subTitle?: string;
description?: string;
gradient: DYNAMIC_BANNERS_SALE_GRADIENTS;
image?: string;
isEco?: boolean;
isGift?: boolean;
pressLink?: string;
adv?: AdvInfo;
bannerButtons?: Array<{
buttonType: BANNER_BUTTON_TYPE;
buttonOrder: number;
buttonText: string;
buttonLink?: string;
isEco?: boolean;
buttonIcon?: string;
ymSpaceCode: string;
ymElementName: string;
}>;
bannerButtons?: DynamicBannerButton[];
tabs?: BannerTab;
};
}
type SpaceWithBanners = Record<BANNER_SPACE_CODE, Partial<Record<TYPE_BANNER, DynamicBanner[]>>>;
export type { DynamicBanner, SpaceWithBanners };
export type { DynamicBanner, DynamicBannerButton, SpaceWithBanners };
+4
View File
@@ -184,6 +184,10 @@ const FEATURE_TOGGLE_MOCK: FeatureToggleResponse = {
featureCode: FEATURE_TOGGLE_NAMES.PRODUCT_FOR_YOU,
isEnabled: true,
},
{
featureCode: FEATURE_TOGGLE_NAMES.STORIES,
isEnabled: true,
},
],
},
};
@@ -32,26 +32,256 @@
}
},
{
"name": "story_feed_accounts_1",
"name": "ausn",
"typeBanner": "storyFeed",
"spaceCode": "accountsIB",
"priority": 2,
"description": "Story feed banner",
"toggles": [],
"imageUrl": "",
"spaceCode": "mainPageIB",
"priority": 1,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"title": "Keep up with updates",
"subtitle": "Stories from your bank",
"gradient": "sale3",
"imageUrl": "",
"pressLink": "https://google.com",
"adv": {
"name": "Romashka",
"inn": "1245124851",
"erid": ""
}
"miniature": {
"name": "АУСН\nДоступно",
"gradient": "sale7",
"imageUrl": "/stories/assets/miniature_1.webp"
},
"title": "АУСН онлайн",
"description": "Автоматизированная упрощенная система налогообложения теперь доступна в Газпромбанке",
"gradient": "sale7",
"imageUrl": "/stories/assets/story_image_1.webp",
"pressLink": "null",
"bannerButtons": [
{
"buttonType": "prymary",
"buttonOrder": 1,
"buttonText": "Подробнее",
"isECO": true,
"buttonLink": "/debt",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "Salary Project",
"typeBanner": "storyFeed",
"spaceCode": "mainPageIB",
"priority": 2,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"miniature": {
"name": "Зарплатный\nпроект",
"gradient": "sale6",
"imageUrl": "/stories/assets/miniature_2.webp"
},
"title": "Зарплатный проект",
"description": "Для сотрудников: 0₽ обслуживание, выпуск стикера, переводы по реквизитам и СБП и другие услуги.",
"gradient": "sale7",
"imageUrl": "/stories/assets/story_image_2.webp",
"pressLink": "null",
"bannerButtons": [
{
"buttonType": "prymary",
"buttonOrder": 1,
"buttonText": "Подробнее",
"isECO": false,
"buttonLink": "https://www.gazprombank.ru/business/salary-project/#first-step?utm_source=ib&utm_campaign=banner_april|d:dop|pn:salary-project|rt:web_bank|rk:old_client|ag:gpb&utm_medium=banner_stories_0404",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "foreign card",
"typeBanner": "storyFeed",
"spaceCode": "mainPageIB",
"priority": 3,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"miniature": {
"name": "Карта\nбез границ",
"gradient": "sale3",
"imageUrl": "/stories/assets/miniature_card_without_borders.webp"
},
"title": "Карта без границ",
"description": "Оплачивайте покупки за границей спокойно. Доставим бесплатно в любую точку России",
"gradient": "sale7",
"imageUrl": "/stories/assets/story_image_2.webp",
"pressLink": "null",
"adv": {
"name": "ООО «Финкросс»",
"inn": "9714051744",
"erid": "2VtzqwET5ji"
},
"bannerButtons": [
{
"buttonType": "prymary",
"buttonOrder": 1,
"buttonText": "Попробовать",
"isECO": false,
"buttonLink": "https://www.fincross.ru/?utm_medium=banner&utm_source=dbo_msb_ib&utm_campaign=d:dcrm|rk:dc_fc|prm:dc_fc_banner",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "partner_check",
"typeBanner": "storyFeed",
"spaceCode": "mainPageIB",
"priority": 4,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"miniature": {
"name": "Проверка контрагентов",
"gradient": "sale7",
"imageUrl": "/stories/assets/miniature_4.webp"
},
"title": "Проверка контрагентов",
"description": "Проверяйте надёжность контрагентов \nдо заключения сделок",
"gradient": "sale7",
"imageUrl": "/stories/assets/story_image_4.webp",
"pressLink": "null",
"bannerButtons": [
{
"buttonType": "primary",
"buttonOrder": 1,
"buttonText": "Подключить",
"isECO": false,
"buttonLink": "/partner-check",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "payments_turkey",
"typeBanner": "storyFeed",
"spaceCode": "mainPageIB",
"priority": 5,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"miniature": {
"name": "Платежи\nв Турцию 0,5%",
"gradient": "sale7",
"imageUrl": "/stories/assets/miniature_payments_turkey.webp"
},
"title": "Платежи в Турцию\nпо выгодной ставке",
"description": "Переводите деньги в рублях и лирах с выгодной комиссией 0,5%, НДС\nне облагается.",
"gradient": "sale7",
"imageUrl": "/stories/assets/story_payments_turkey.webp",
"pressLink": "null",
"bannerButtons": [
{
"buttonType": "primary",
"buttonOrder": 1,
"buttonText": "Оставить заявку",
"isECO": false,
"buttonLink": "https://app.ab-payments.ru/auth",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "business_cards",
"typeBanner": "storyFeed",
"spaceCode": "mainPageIB",
"priority": 6,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"miniature": {
"name": "Бизнес-карта\nОнлайн",
"gradient": "sale5",
"imageUrl": "/stories/assets/miniature_6.webp"
},
"title": "Бизнес-карта",
"description": "Оформите карту для личных \nи деловых расходов",
"gradient": "sale5",
"imageUrl": "/stories/assets/story_image_6.webp",
"pressLink": "null",
"bannerButtons": [
{
"buttonType": "primary",
"buttonOrder": 1,
"buttonText": "Выпустить карту",
"isECO": false,
"buttonLink": "/business-cards/cards",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "bank_guarantees",
"typeBanner": "storyFeed",
"spaceCode": "mainPageIB",
"priority": 7,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"miniature": {
"name": "Экспресс-гарантия",
"gradient": "sale4",
"imageUrl": "/stories/assets/miniature_7.webp"
},
"title": "Электронная\nбанковская гарантия",
"description": "Оформите банковскую гарантию онлайн на сумму до 200 млн рублей. \nБез залога и поручителей",
"gradient": "sale4",
"imageUrl": "/stories/assets/story_image_6.webp",
"pressLink": "null",
"bannerButtons": [
{
"buttonType": "primary",
"buttonOrder": 1,
"buttonText": "Отправить заявку",
"isECO": false,
"buttonLink": "?productCode=ebg&sourcePage=stories&sourceSystem=MSB",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "catch_the_wave",
"typeBanner": "storyFeed",
"spaceCode": "mainPageIB",
"priority": 8,
"description": "",
"toggles": ["mainpageStoriesDisplayIBMSB"],
"banner": {
"miniature": {
"name": "Лови\nволну",
"gradient": "sale9",
"imageUrl": "/stories/assets/miniature_10.webp"
},
"title": "Ловите Волну\nв вашем бизнесе",
"description": "Оплата с iPhone без карты и изменений\nв кассе — больше способов оплаты\nи роста продаж",
"gradient": "sale9",
"imageUrl": "/stories/assets/story_image_10.webp",
"pressLink": "null",
"bannerButtons": [
{
"buttonType": "primary",
"buttonOrder": 1,
"buttonText": "Подключить волну",
"isECO": true,
"buttonLink": "/paymenthub/lk",
"buttonIconUrl": "null"
}
]
}
},
{
"name": "product_for_you_mainpage_4",
"typeBanner": "productForYou",
@@ -181,4 +411,3 @@
}
]
}
@@ -299,10 +299,14 @@
"featureCode": "depositQuestionnaireIBMSB",
"isEnabled": false
},
{
{
"featureCode": "counterPartyIBMSB",
"isEnabled": true
},
{
"featureCode": "mainpageStoriesDisplayIBMSB",
"isEnabled": true
}
]
}
}
+7
View File
@@ -19,6 +19,13 @@ const config: IWebpackAppConfig = {
},
devServerOptions: {
port: 8000,
setupMiddlewares: (middlewares, devServer) => {
if (devServer?.server) {
devServer.server.setMaxListeners(20);
}
return middlewares;
},
server: process.env.SECURE ? 'https' : 'http',
proxy: [
{
@@ -1,60 +1,78 @@
const mockStories = [
{
id: '0001',
image: '/stories/assets/miniature_1.webp',
name: 'Счёт для бизнеса',
ymCode: 'stories_slide_account',
items: [
/* eslint-disable sonarjs/no-duplicate-string */
const mockBanners = {
mainPageIB: {
storyFeed: [
{
id: '1001',
gradient: 'copper',
image: '/stories/assets/story_image_1.webp',
showTime: 10_000,
content: {
title: 'Счёт для бизнеса',
description: '5 тарифов для выгодных расчётов в рублях и валюте',
buttons: [
id: 'ausn',
name: 'ausn',
typeBanner: 'storyFeed',
description: '',
toggles: ['mainpageStoriesDisplayIBMSB'],
miniature: { name: 'АУСН Доступно', gradient: 'sale7', image: 'http://localhost:8000/msb-host/stories/assets/miniature_1.webp' },
banner: {
ymElementName: 'storyFeed_ausn',
ymSpaceCode: 'mainPageIB_storyFeed',
title: 'АУСН онлайн',
description: 'Автоматизированная упрощённая система налогообложения теперь доступна в Газпромбанке',
gradient: 'sale7',
image: 'http://localhost:8000/msb-host/stories/assets/story_image_1.webp',
pressLink: 'null',
bannerButtons: [
{
link: '/open-account/new',
linkType: 'eco',
ymCode: 'stories_slide_account_open_account',
text: 'Открыть счёт',
buttonOrder: 1,
buttonText: 'Кнопка 1',
buttonType: 'prymary',
isEco: true,
buttonIcon: 'http://localhost:8000/msb-hostnull',
buttonLink: '/debt',
ymElementName: 'Подробнее',
ymSpaceCode: 'storyFeed_ausn',
},
],
},
},
{
id: 'salary_project',
name: 'salary_project',
typeBanner: 'storyFeed',
description: '',
toggles: ['mainpageStoriesDisplayIBMSB'],
miniature: {
name: 'Зарплатный проект',
gradient: 'sale6',
image: 'http://localhost:8000/msb-host/stories/assets/miniature_2.webp',
},
banner: {
ymElementName: 'storyFeed_salary_project',
ymSpaceCode: 'mainPageIB_storyFeed',
title: 'Зарплатный проект',
description: 'Для сотрудников: 0₽ обслуживание, выпуск стикера, переводы по реквизитам и СБП и другие услуги.',
gradient: 'sale7',
image: 'http://localhost:8000/msb-host/stories/assets/story_image_2.webp',
pressLink: 'null',
bannerButtons: [
{
link: 'https://www.gazprombank.ru/business/account-open/#tariffs',
text: 'Подробнее',
buttonOrder: 1,
buttonText: 'Кнопка 2',
buttonType: 'prymary',
isEco: false,
buttonIcon: 'http://localhost:8000/msb-hostnull',
buttonLink:
'https://www.gazprombank.ru/business/salary-project/#first-step?utm_source=ib&utm_campaign=banner_april|d:dop|pn:salary-project|rt:web_bank|rk:old_client|ag:gpb&utm_medium=banner_stories_0404',
ymElementName: 'Подробнее',
ymSpaceCode: 'storyFeed_salary_project',
},
],
},
},
],
},
{
id: '0002',
image: '/stories/assets/miniature_2.webp',
name: 'Депозиты для бизнеса',
ymCode: 'stories_slide_deposit',
items: [
{
id: '1002',
gradient: 'sunsetSky',
image: '/stories/assets/story_image_2.webp',
showTime: 10_000,
content: {
title: 'Депозиты для бизнеса',
description: 'Размещайте свободные средства на депозитах и получайте доход',
buttons: [
{
link: '/deposits/treasury-deals',
linkDocType: 'DEPOSIT',
text: 'Открыть депозит',
ymCode: 'stories_slide_deposit_open_deposit',
},
],
},
},
],
},
];
};
export { mockStories };
const mockBannersEmptyState = {
mainPageIB: {
storyFeed: [],
},
};
export { mockBanners, mockBannersEmptyState };
@@ -1,29 +1,26 @@
/* eslint-disable jest/no-mocks-import */
import '@testing-library/jest-dom';
import { render } from '@msb/shared/lib/tests/customRender';
import { fireEvent, screen } from '@testing-library/react';
import { mockStories } from '../__mocks__/mockStories';
import { mockBanners, mockBannersEmptyState } from '../__mocks__/mockStories';
import { StoriesBlock } from '../ui/StoriesBlock';
const mockWindowOpen = jest.fn();
const mockUseStories = jest.fn();
jest.mock('../models', () => ({
__esModule: true,
useStories: () => mockUseStories(),
}));
const mockUseBanners = jest.fn(() => ({ isDynamicBannersLoading: false, banners: mockBanners }));
const featureTogglesMock = {
features: [],
features: [{ featureCode: 'mainpageStoriesDisplayIBMSB', isEnabled: true }],
error: null,
};
const useFeatureTogglesMock = jest.fn(() => ({ isEnabled: true }));
const useAppContextMock = jest.fn(() => ({
const useAppContextMock = jest.fn();
useAppContextMock.mockImplementation(() => ({
organizations: [],
userProfile: {},
...mockUseBanners(),
isUserProfileError: false,
refetchUserProfile: jest.fn(),
organizationsError: null,
@@ -69,7 +66,7 @@ describe('StoriesBlock', () => {
});
test('должен показывать скелетоны до того как загрузятся данные', () => {
mockUseStories.mockReturnValue({ isLoading: true, stories: [] });
mockUseBanners.mockReturnValue({ isDynamicBannersLoading: true, banners: mockBannersEmptyState });
render(<StoriesBlock />);
@@ -77,41 +74,40 @@ describe('StoriesBlock', () => {
});
test('должен отображать данные и скрывать скелетоны когда загрузка завершена', () => {
mockUseStories.mockReturnValue({ isLoading: false, stories: mockStories });
mockUseBanners.mockReturnValue({ isDynamicBannersLoading: false, banners: mockBanners });
render(<StoriesBlock />);
mockStories.forEach(story => {
expect(screen.getByText(story.name)).toBeInTheDocument();
mockBanners.mainPageIB.storyFeed.forEach(story => {
expect(screen.getByText(story.miniature.name)).toBeInTheDocument();
});
expect(screen.queryAllByTestId('skeleton')).toHaveLength(0);
});
test('должна открыться карусель с историями при клике на элемент в ленте', () => {
const firstStoryContent = mockStories[0].items[0].content;
const firstStoryContent = mockBanners.mainPageIB.storyFeed[0].banner;
mockUseStories.mockReturnValue({ isLoading: false, stories: mockStories });
mockUseBanners.mockReturnValue({ isDynamicBannersLoading: false, banners: mockBanners });
render(<StoriesBlock />);
const openStoryButton = screen.getByTitle(mockStories[0].name);
const openStoryButton = screen.getByTitle(mockBanners.mainPageIB.storyFeed[0].miniature.name);
fireEvent.click(openStoryButton);
expect(screen.getByText(firstStoryContent.description)).toBeInTheDocument();
expect(screen.getByText(firstStoryContent.buttons[0].text)).toBeInTheDocument();
expect(screen.getByText(firstStoryContent.buttons[1].text)).toBeInTheDocument();
expect(screen.getByText(firstStoryContent.bannerButtons[0].buttonText)).toBeInTheDocument();
});
test('должна закрыться карусель с историями при клике на кнопку закрытия', () => {
const firstStoryContent = mockStories[0].items[0].content;
const firstStoryContent = mockBanners.mainPageIB.storyFeed[0].banner;
mockUseStories.mockReturnValue({ isLoading: false, stories: mockStories });
mockUseBanners.mockReturnValue({ isDynamicBannersLoading: false, banners: mockBanners });
render(<StoriesBlock />);
const openStoryButton = screen.getByTitle(mockStories[0].name);
const openStoryButton = screen.getByTitle(mockBanners.mainPageIB.storyFeed[0].miniature.name);
fireEvent.click(openStoryButton);
@@ -130,9 +126,9 @@ describe('StoriesBlock', () => {
});
test('должен произойти переход по ссылке при клике на кнопку в истории', () => {
const firstStoryContent = mockStories[0].items[0].content;
const firstStoryContent = mockBanners.mainPageIB.storyFeed[0].banner;
mockUseStories.mockReturnValue({ isLoading: false, stories: mockStories });
mockUseBanners.mockReturnValue({ isDynamicBannersLoading: false, banners: mockBanners });
const originalWindowOpen = window.open;
@@ -140,11 +136,11 @@ describe('StoriesBlock', () => {
render(<StoriesBlock />);
const openStoryButton = screen.getByTitle(mockStories[0].name);
const openStoryButton = screen.getByTitle(mockBanners.mainPageIB.storyFeed[0].miniature.name);
fireEvent.click(openStoryButton);
const firstActionButton = screen.getByText(firstStoryContent.buttons[0].text);
const firstActionButton = screen.getByText(firstStoryContent.bannerButtons[0].buttonText);
expect(firstActionButton).toBeInTheDocument();
@@ -156,7 +152,7 @@ describe('StoriesBlock', () => {
});
test('должен соответствовать снепшоту', () => {
mockUseStories.mockReturnValue({ isLoading: false, stories: mockStories });
mockUseBanners.mockReturnValue({ isDynamicBannersLoading: false, banners: mockBanners });
const { asFragment } = render(<StoriesBlock />);
@@ -33,20 +33,20 @@ exports[`StoriesBlock должен соответствовать снепшот
>
<button
class="css-1iorir7"
title="Счёт для бизнеса"
title="АУСН Доступно"
type="button"
>
<div
class="css-van0yd"
class="css-11rm4p0"
>
<div
class="css-117yjny"
color=""
>
<img
alt="Счёт для бизнеса"
alt="АУСН Доступно"
class="css-1qunxpv"
src="http://localhost/stories/assets/miniature_1.webp"
src="http://localhost:8000/msb-host/stories/assets/miniature_1.webp"
/>
<span
class="css-1scwbqz"
@@ -56,7 +56,7 @@ exports[`StoriesBlock должен соответствовать снепшот
color="#FFFFFF"
font-size="typography.P3.fontSize.S"
>
Счёт для бизнеса
АУСН Доступно
</span>
</span>
</div>
@@ -65,20 +65,20 @@ exports[`StoriesBlock должен соответствовать снепшот
</button>
<button
class="css-1iorir7"
title="Депозиты для бизнеса"
title="Зарплатный проект"
type="button"
>
<div
class="css-van0yd"
class="css-refupn"
>
<div
class="css-117yjny"
color=""
>
<img
alt="Депозиты для бизнеса"
alt="Зарплатный проект"
class="css-1qunxpv"
src="http://localhost/stories/assets/miniature_2.webp"
src="http://localhost:8000/msb-host/stories/assets/miniature_2.webp"
/>
<span
class="css-1scwbqz"
@@ -88,7 +88,7 @@ exports[`StoriesBlock должен соответствовать снепшот
color="#FFFFFF"
font-size="typography.P3.fontSize.S"
>
Депозиты для бизнеса
Зарплатный проект
</span>
</span>
</div>
@@ -28,18 +28,20 @@ describe('useVisitedStories', () => {
expect(result.current.viewedStories).not.toContain(STORY_ID_2);
expect(window.localStorage.getItem(VIEWED_STORIES)).not.toBe(JSON.stringify([STORY_ID_2]));
act(() => result.current.handleViewStoryGroup(STORY_ID_2));
act(() =>
result.current.handleViewStoryGroup(STORY_ID_2, { ymSpaceCode: 'mainPageIB_storyFeed', ymElementName: 'storyFeed_catch_the_wave' })
);
expect(result.current.viewedStories).toContain(STORY_ID_2);
expect(window.localStorage.getItem(VIEWED_STORIES)).toBe(JSON.stringify([STORY_ID_2]));
});
test('все сохраненные истории должны быть уникальными', () => {
test('все сохранённые истории должны быть уникальными', () => {
const { result } = renderHook(() => useVisitedStories());
act(() => {
result.current.handleViewStoryGroup(STORY_ID_3);
result.current.handleViewStoryGroup(STORY_ID_3);
result.current.handleViewStoryGroup(STORY_ID_3, { ymSpaceCode: 'mainPageIB_storyFeed', ymElementName: 'storyFeed_catch_the_wave' });
result.current.handleViewStoryGroup(STORY_ID_3, { ymSpaceCode: 'mainPageIB_storyFeed', ymElementName: 'storyFeed_catch_the_wave' });
});
expect(result.current.viewedStories).toHaveLength(1);
@@ -0,0 +1,59 @@
import type { DynamicBanner, DynamicBannerButton } from '@msb/http';
// TODO: в последствии нужно переделать компонент под новую структуру
/**
* Маппер для преобразования формата динамических баннеров в формат stories.
*/
const mapStoriesToPreviousFormat = (dynamicBanners: DynamicBanner[]) =>
dynamicBanners.map(item => {
const { name, banner, miniature, toggles } = item;
const { adv, bannerButtons = [], image, title, description, ymSpaceCode, ymElementName, gradient } = banner;
const advertising = adv ? `Реклама. erid: ${adv.erid}\n${adv.name} ИНН ${adv.inn}` : undefined;
const getLinkType = (button: DynamicBannerButton) => {
switch (true) {
case button.isEco:
return 'eco';
case button.buttonLink?.includes('ebg'):
return 'guarantees';
default:
return;
}
};
return {
id: name,
image: miniature?.image,
name: miniature?.name,
gradient: miniature?.gradient,
ymSpaceCode,
ymElementName,
toggles,
items: [
{
id: `${name}_more`,
gradient,
image,
showTime: 10_000,
content: {
title,
description,
advertising,
buttons: Array.isArray(bannerButtons)
? bannerButtons.map(button => ({
link: button.buttonLink ?? '',
text: button.buttonText ?? '',
linkType: getLinkType(button),
ymSpaceCode: button.ymSpaceCode,
ymElementName: button.ymSpaceCode,
}))
: [],
},
},
],
};
});
export { mapStoriesToPreviousFormat };
@@ -1,6 +1,6 @@
import { YM_VIEW_ELEMENT_STORIES_LS_KEY } from '@/shared/constants';
import { handleReachGoal, YM_GOALS } from '@msb/shared';
import { useRef, useState } from 'react';
import { handleReachGoal, YM_GOALS } from '@msb/shared';
import { YM_VIEW_ELEMENT_STORIES_LS_KEY } from '@/shared/constants';
const VIEWED_STORIES = 'stories';
@@ -28,17 +28,18 @@ export const useVisitedStories = () => {
const [viewedStories, setViewedStories] = useState<string[]>(initialStories.current);
const handleViewStoryGroup = (storyGroupId: string, ymCode?: string): void => {
const handleViewStoryGroup = (storyGroupId: string, ymCodes: { ymSpaceCode?: string; ymElementName?: string }): void => {
const ymViewElementStories = getStories(YM_VIEW_ELEMENT_STORIES_LS_KEY);
if (ymCode && ymViewElementStories.includes(ymCode)) {
if (ymCodes.ymElementName && ymViewElementStories.includes(ymCodes.ymElementName)) {
return;
} else if (ymCode) {
const newValue = [...new Set([...ymViewElementStories, ymCode])];
} else if (ymCodes.ymElementName && ymCodes.ymSpaceCode) {
const newValue = [...new Set([...ymViewElementStories, ymCodes.ymElementName])];
localStorage.setItem(YM_VIEW_ELEMENT_STORIES_LS_KEY, JSON.stringify(newValue));
handleReachGoal(YM_GOALS.VIEW_ELEMENT, {
[YM_GOALS.VIEW_ELEMENT]: { main_page: { element_name: ymCode } },
[YM_GOALS.VIEW_ELEMENT]: { [ymCodes.ymSpaceCode]: { element_name: `${ymCodes.ymElementName}_more` } },
});
}
@@ -1,18 +1,17 @@
import type { BestRateDealType, FEATURE_TOGGLE_NAMES } from '@msb/http';
import type { DOC_TYPES } from '@msb/shared';
type Link = 'eco' | 'guarantees';
interface StoryButton {
link: string;
text: string;
linkType?: Link;
linkType?: string | undefined;
linkDocType?: DOC_TYPES;
ymCode?: string;
ymSpaceCode?: string;
ymElementName?: string;
fallbackHref?: {
toggle: FEATURE_TOGGLE_NAMES;
href: string;
linkType?: Link;
linkType?: string;
};
}
@@ -12,10 +12,10 @@ import {
} from '@msb/shared';
import { useHistory } from 'react-router-dom';
import { GRADIENT_STORIES, RADIANT_GRADIENT_STORIES } from '../../constants';
import { mapStoriesToPreviousFormat } from '../../lib/mapStoriesList';
import { sortStoriesByVisit } from '../../lib/sortStoriesByVisit';
import { useVisitedStories } from '../../lib/useVisitedStories';
import type { StoryButton } from '../../models';
import { useStories } from '../../models';
import type { IStoryGroupItem } from '../StoriesGroup';
import StoriesGroup, { StoryContent } from '../StoriesGroup';
@@ -23,13 +23,14 @@ const { ActionButton, TextBlock } = StoryContent;
const StoriesBlock = (): ReactElement => {
const history = useHistory();
const { stories, isLoading } = useStories();
const { initialStories, viewedStories, handleViewStoryGroup } = useVisitedStories();
const { handleReachGoal } = useYaMetrika();
const { features, error } = useFeatureTogglesContext();
const { organizations } = useAppContext();
const { organizations, banners, isDynamicBannersLoading } = useAppContext();
const storiesList = useMemo(() => mapStoriesToPreviousFormat(banners.mainPageIB.storyFeed ?? []), [banners.mainPageIB.storyFeed]);
const getFeatureToggle = useCallback(
(toggleKey: string) => {
@@ -69,25 +70,27 @@ const StoriesBlock = (): ReactElement => {
const handleActionButtonClick = useCallback(
(button: StoryButton) => {
if (button.ymCode) {
const { ymSpaceCode, ymElementName, fallbackHref, link, linkType, linkDocType } = button;
if (ymSpaceCode && ymElementName) {
handleReachGoal(YM_GOALS.BUTTON_CLICK, {
[YM_GOALS.BUTTON_CLICK]: { main_page: { element_name: button.ymCode } },
[YM_GOALS.BUTTON_CLICK]: { [ymSpaceCode]: { element_name: [ymElementName] } },
});
}
if (!button.fallbackHref?.toggle || !button.fallbackHref.href) {
navigateTo(button.link, button.linkType, button.linkDocType);
if (!fallbackHref?.toggle || !fallbackHref.href) {
navigateTo(link, linkType, linkDocType);
return;
}
const { isEnabled } = getFeatureToggle(button.fallbackHref.toggle);
const { isEnabled } = getFeatureToggle(fallbackHref.toggle);
// если сервис на который хотим перейти закрыт ФТ, то переводим на аналогичный сервис в БОЛ
if (isEnabled) {
navigateTo(button.link, button.linkType, button.linkDocType);
navigateTo(link, linkType, linkDocType);
} else {
navigateTo(button.fallbackHref.href, button.fallbackHref.linkType);
navigateTo(fallbackHref.href, fallbackHref.linkType);
}
},
[getFeatureToggle, handleReachGoal, navigateTo]
@@ -95,29 +98,36 @@ const StoriesBlock = (): ReactElement => {
const storiesGroup: IStoryGroupItem[] = useMemo(
() =>
stories.reduce((acc: IStoryGroupItem[], story) => {
if (story.name) {
storiesList.reduce((acc: IStoryGroupItem[], story) => {
const isEnabled =
story.toggles?.every(toggleKey => {
const feature = features.find(f => f.featureCode === toggleKey);
return feature?.isEnabled;
}) ?? false;
if (story.name && isEnabled) {
acc.push({
color: '',
id: story.id,
id: story.id ?? '',
mode: 'dark',
name: story.name,
ymCode: story.ymCode,
dealType: story.dealType,
ymSpaceCode: story.ymSpaceCode,
ymElementName: story.ymElementName,
gradient: RADIANT_GRADIENT_STORIES[story.gradient as keyof typeof RADIANT_GRADIENT_STORIES] || RADIANT_GRADIENT_STORIES.sale2,
image: { source: `${window.location.origin}${story.image}${queryStringVersion}` },
image: { source: `${story.image}${queryStringVersion}` },
items: story.items.map(item => ({
id: item.id,
color: '',
gradient: GRADIENT_STORIES[item.gradient as keyof typeof GRADIENT_STORIES] || GRADIENT_STORIES.sale2,
image: { source: `${window.location.origin}${item.image}${queryStringVersion}` },
showTime: item.showTime,
image: { source: `${item.image}${queryStringVersion}` },
showTime: 10_000,
type: 'image',
storyContent: (
<StoryContent
buttons={item.content.buttons.map(button => (
<ActionButton
key={button.ymCode}
key={button.link}
dataAction="link"
shape="default"
size="L"
@@ -137,12 +147,12 @@ const StoriesBlock = (): ReactElement => {
return acc;
}, []),
[stories, handleActionButtonClick]
[storiesList, features, handleActionButtonClick]
);
return (
<StoriesGroup
isLoading={isLoading}
isLoading={isDynamicBannersLoading}
storyGroups={sortStoriesByVisit(storiesGroup, initialStories)}
viewedStoriesId={viewedStories}
onViewStoryGroup={handleViewStoryGroup}
@@ -1,9 +1,9 @@
import { memo, type ReactElement } from 'react';
import { memo } from 'react';
import type { ReactElement } from 'react';
import { lightTheme, Text } from '@fractal-ui/styling';
import { FEATURE_TOGGLE_NAMES } from '@msb/http';
import { MEDIA, TrackedElement, useBestRatesMainPage, useFeatureToggles, useMediaQuery } from '@msb/shared';
import { useToggle } from '../../lib/hooks/useToggle';
import { SLIDE_WIDTH_DESKTOP, SLIDE_WIDTH_MOBILE } from '../BlockStories/constants';
import { ItemName, Layout, Picture, PictureLayout, Wrapper } from './BlockStoriesItem.styles';
import type { IBlockStoriesItemProps } from './types';
@@ -14,7 +14,8 @@ const BlockStoriesItem = ({
onClick,
onViewElement,
image,
ymCode,
ymElementName,
ymSpaceCode,
color,
title,
gradient,
@@ -55,11 +56,11 @@ const BlockStoriesItem = ({
<ItemName>
<Text.P3Short as="span" color={lightTheme.colors.control.bg}>
{title}{' '}
{isDynamicBannersEnabled && bestRates && dealType && bestRates[dealType] && `до ${bestRates[dealType]!.replace('.', ',')}%`}
{isDynamicBannersEnabled && bestRates && dealType && bestRates[dealType] && `до ${bestRates[dealType].replace('.', ',')}%`}
</Text.P3Short>
</ItemName>
</PictureLayout>
<TrackedElement goalParams={{ main_page: { element_name: ymCode } }} />
{ymSpaceCode && ymElementName && <TrackedElement goalParams={{ [ymSpaceCode]: { element_name: [ymElementName] } }} />}
</Layout>
</Wrapper>
);
@@ -15,7 +15,8 @@ export interface IBlockStoriesItemProps {
isViewed?: boolean;
gradient?: string;
dealType?: BestRateDealType;
ymCode?: string;
ymSpaceCode?: string;
ymElementName?: string;
onClick?(): void;
onViewElement?(): void;
}
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import type React from 'react';
import { useRef } from 'react';
@@ -60,6 +61,7 @@ export function useStoriesNavigation({ onPrevious, onNext, onPause, isActive }:
const isClick = Math.abs(Number(mouseDownClientX.current) - mouseUpClientX) <= 5;
// @ts-ignore
if (!isClick || (mouseDown.current && pause)) {
play();
@@ -5,7 +5,8 @@ import { sortStoriesByVisit } from '../../../../lib/sortStoriesByVisit';
import { useEvent } from '../../lib/hooks/useEvent';
import { BlockStories } from '../BlockStories';
import { BlockStoriesItem } from '../BlockStoriesItem';
import { StoriesModal, type TStoryRenderer } from '../StoriesModal';
import { StoriesModal } from '../StoriesModal';
import type { TStoryRenderer } from '../StoriesModal';
import { Wrapper } from './StoriesGroup.styles';
import type { IStoryGroupItem } from './components/StoryGroupItem';
import { StoryGroupItem } from './components/StoryGroupItem';
@@ -27,8 +28,8 @@ const StoriesGroup: FC<IStoriesGroupProps> = ({
const [storyGroupIndex, setStoryGroupIndex] = useState<number | undefined>(); // истории в гор.слайдере
const [storyItems, setStoryItems] = useState<IStoryGroupItem[]>(sortedItems); // истории в карусели
const handleStoryGroupView = useEvent((storyId: string, ymCode?: string) => {
onViewStoryGroup?.(storyId, ymCode);
const handleStoryGroupView = useEvent((storyId: string, ymCodes: { ymSpaceCode?: string; ymElementName?: string }) => {
onViewStoryGroup?.(storyId, ymCodes);
});
const changeStoriesHandler = (slideIndex?: number): void => {
@@ -47,11 +48,11 @@ const StoriesGroup: FC<IStoriesGroupProps> = ({
setStoryGroupIndex(slideIndex);
setStoryItems(sortedItems);
const { id, name, ymCode } = items[slideIndex];
const { id, name, ymElementName, ymSpaceCode } = items[slideIndex];
if (ymCode) {
if (ymElementName && ymSpaceCode) {
handleReachGoal(YM_GOALS.BUTTON_CLICK, {
[YM_GOALS.BUTTON_CLICK]: { main_page: { element_name: ymCode } },
[YM_GOALS.BUTTON_CLICK]: { [ymSpaceCode]: { element_name: [ymElementName] } },
});
}
@@ -79,7 +80,7 @@ const StoriesGroup: FC<IStoriesGroupProps> = ({
};
const renderGroupItem = (storiesGroup: IStoryGroupItem, index: number): ReactElement => {
const { image, name, color, id, gradient, dealType, ymCode } = storiesGroup;
const { image, name, color, id, gradient, dealType, ymElementName, ymSpaceCode } = storiesGroup;
const isViewedStory = viewedStoriesId.includes(id);
const imageSource: string | undefined = typeof image === 'string' ? image : image?.source;
@@ -92,7 +93,8 @@ const StoriesGroup: FC<IStoriesGroupProps> = ({
image={imageSource}
isViewed={isViewedStory}
title={name}
ymCode={ymCode}
ymElementName={ymElementName}
ymSpaceCode={ymSpaceCode}
onClick={() => onShowHandler(index)}
onViewElement={() => handleViewGroupItem(storiesGroup, index)}
/>
@@ -117,7 +119,9 @@ const StoriesGroup: FC<IStoriesGroupProps> = ({
onHandleStoryEvent={onHandleStoryEvent}
onNextStory={nextStoryHandler}
onPreviousStory={onPreviousStory}
onViewStoryGroup={() => handleStoryGroupView(storyGroup.id, storyGroup.ymCode ? `${storyGroup.ymCode}_more` : '')}
onViewStoryGroup={() =>
handleStoryGroupView(storyGroup.id, { ymElementName: storyGroup.ymElementName, ymSpaceCode: storyGroup.ymSpaceCode })
}
/>
);
};
@@ -30,8 +30,9 @@ export interface IStoryGroupItem {
image?: TStoryImage | string;
/** Градиент обводки миниатюры */
gradient?: string;
/** Код Яндекс метрики */
ymCode?: string;
/** Коды Яндекс метрики */
ymSpaceCode?: string;
ymElementName?: string;
/** Код привязки к динамической ставке */
dealType?: BestRateDealType;
}
@@ -23,7 +23,7 @@ export interface IStoriesGroupProps extends IBaseComponent {
/** Обработчик событий истории. */
onHandleStoryEvent?: StoryEventHandler;
/** Событие просмотра группы историй. */
onViewStoryGroup?(storyGroupId: string, ymCode?: string): void;
onViewStoryGroup?(storyGroupId: string, ymCodes: { ymSpaceCode?: string; ymElementName?: string }): void;
/** Список id просмотренных групп историй. */
viewedStoriesId?: string[];
/** Сортировка историй после просмотра. */
+1 -1
View File
@@ -2,7 +2,7 @@ export { FinancialDashboard } from './FinancialDashboard';
export { Balance } from './Balance';
export { QuickActions } from './QuickActions';
export { ProductCarousel } from './ProductCarousel';
export { default as StoriesBlock } from './StoriesBlock';
export { StoriesBlock } from './StoriesBlock';
export { OperationsHistory } from './OperationsHistory';
export { ClientFeedback } from './ClientFeedback';
export { ImportantNotifications } from './ImportantNotifications';