From 8fe4d9300018cff2e6563128c387289700c5fba4 Mon Sep 17 00:00:00 2001 From: Maksym Sadovnychyy Date: Mon, 1 Aug 2022 19:14:25 +0200 Subject: [PATCH] (feat): header model and reducer, reserved words consolidation --- webapi/ClientApp/src/App.tsx | 22 ++-- webapi/ClientApp/src/enumerations/index.ts | 17 +++ webapi/ClientApp/src/models/abstractions.ts | 6 +- webapi/ClientApp/src/models/index.ts | 14 +++ webapi/ClientApp/src/models/responses.ts | 5 +- webapi/ClientApp/src/pages/Home/index.tsx | 44 ++++++-- webapi/ClientApp/src/store/index.ts | 3 + .../ClientApp/src/store/reducers/Content.ts | 104 ++++++++++++++++-- webapi/ClientApp/src/store/reducers/Header.ts | 88 +++++++++++++++ 9 files changed, 267 insertions(+), 36 deletions(-) create mode 100644 webapi/ClientApp/src/enumerations/index.ts create mode 100644 webapi/ClientApp/src/store/reducers/Header.ts diff --git a/webapi/ClientApp/src/App.tsx b/webapi/ClientApp/src/App.tsx index 3cabb5a..e49d34c 100644 --- a/webapi/ClientApp/src/App.tsx +++ b/webapi/ClientApp/src/App.tsx @@ -42,12 +42,7 @@ const App = () => { const { pathname } = useLocation() const dispatch = useDispatch() - const { content, loader } = useSelector((state: ApplicationState) => state) - - - const { - siteName = "" - } = content ? content : {} + const { content, header, loader } = useSelector((state: ApplicationState) => state) useEffect(() => { dispatch(settingsActionCreators.requestContent()) @@ -60,14 +55,19 @@ const App = () => { }) }, [pathname]) + + const { + title = "", + link = {}, + meta = {} + } = header ? header : {} + return <> - {siteName} + {title} - - - - + {Object.keys(link).map((rel, index) => )} + {Object.keys(meta).map((name, index) => )} diff --git a/webapi/ClientApp/src/enumerations/index.ts b/webapi/ClientApp/src/enumerations/index.ts new file mode 100644 index 0000000..8c3b8dc --- /dev/null +++ b/webapi/ClientApp/src/enumerations/index.ts @@ -0,0 +1,17 @@ + + +enum ReservedWords { + siteName = "{siteName}", + siteUrl = "{siteUrl}", + quantity = "{quantity}", + productTitle = "{productTitle}", + currency = "{currency}", + date = "{date}", + readTime = "{readTime}", + blogTitle = "{blogTitle}", + nickName = "{nickName}" +} + +export { + ReservedWords +} \ No newline at end of file diff --git a/webapi/ClientApp/src/models/abstractions.ts b/webapi/ClientApp/src/models/abstractions.ts index fe3ec07..7ba6e62 100644 --- a/webapi/ClientApp/src/models/abstractions.ts +++ b/webapi/ClientApp/src/models/abstractions.ts @@ -1,11 +1,11 @@ -import { AuthorModel, FormItemModel, ImageModel } from "./" +import { AuthorModel, FormItemModel, HeaderModel, ImageModel } from "./" export interface RequestModel { [key: string]: string | undefined } -export interface ResponseModel { } +export interface ResponseModel {} export interface AddressPageSectionModel extends PageSectionModel { firstName: FormItemModel, @@ -19,7 +19,7 @@ export interface AddressPageSectionModel extends PageSectionModel { } export interface PageModel { - + header: HeaderModel, } export interface PageSectionModel { diff --git a/webapi/ClientApp/src/models/index.ts b/webapi/ClientApp/src/models/index.ts index d7c80a2..ea3854f 100644 --- a/webapi/ClientApp/src/models/index.ts +++ b/webapi/ClientApp/src/models/index.ts @@ -93,3 +93,17 @@ export interface TestimonialModel { } + + +export interface HeaderLink { + [key: string]: string +} + +export interface Meta { + [key: string]: string +} +export interface HeaderModel { + title: string, + link: HeaderLink, + meta: Meta +} \ No newline at end of file diff --git a/webapi/ClientApp/src/models/responses.ts b/webapi/ClientApp/src/models/responses.ts index 710b3e6..59f0ed8 100644 --- a/webapi/ClientApp/src/models/responses.ts +++ b/webapi/ClientApp/src/models/responses.ts @@ -1,4 +1,4 @@ -import { BlogItemModel, CategoryModel, CommentModel, LocalizationModel, MenuItemModel, PaginationModel, RouteModel, ShopItemModel } from "./" +import { BlogItemModel, CategoryModel, CommentModel, HeaderModel, LocalizationModel, MenuItemModel, PaginationModel, RouteModel, ShopItemModel } from "./" import { ResponseModel } from "./abstractions" import * as Pages from "./pages" @@ -32,8 +32,9 @@ export interface GetShopCartResponseModel extends ResponseModel { // Static content response model export interface GetContentResponseModel extends ResponseModel { siteName: string, + siteUrl: string, - helmet: any, + header: HeaderModel, localization: LocalizationModel, diff --git a/webapi/ClientApp/src/pages/Home/index.tsx b/webapi/ClientApp/src/pages/Home/index.tsx index 76c6c8b..87b1d4c 100644 --- a/webapi/ClientApp/src/pages/Home/index.tsx +++ b/webapi/ClientApp/src/pages/Home/index.tsx @@ -7,13 +7,14 @@ import { useDispatch, useSelector } from 'react-redux' import { ApplicationState } from '../../store' import { actionCreators as loaderActionCreators } from '../../store/reducers/Loader' import { actionCreators as blogFeaturedActionCreators } from '../../store/reducers/BlogFeatured' +import { actionCreators as headerActionCreators } from '../../store/reducers/Header' // Reactstrap import { Card, CardBody, CardFooter, CardImg, Col, Container, Row } from 'reactstrap' // Models (interfaces) import { CallToActionSectionModel, FeaturedBlogsSectionModel, FeaturesSectionModel, TestimonialsSectionModel, TitleSectionModel } from '../../models/pageSections' -import { BlogItemModel, FeatureModel, TestimonialModel } from '../../models' +import { BlogItemModel, FeatureModel, HeaderModel, TestimonialModel } from '../../models' // Custom components import { FeatherIcon } from '../../components/FeatherIcons' @@ -155,9 +156,9 @@ const FeaturedBlogsSection: FC = ({ const CallToActionSection: FC = ({ - title = "", - text = "", - privacyDisclaimer = "", + title, + text, + privacyDisclaimer, email = { placeHolder: "", title: "" @@ -202,12 +203,37 @@ const Home = () => { }, 1000) }, [content?.isLoading, blogFeatured?.isLoading]) + const { + header = {}, + titleSection = { + title: "", + text: "" + }, + featuresSection = {}, + testimonialsSection = {}, + featuredBlogsSection = {}, + callToActionSection = { + title: "", + text: "", + privacyDisclaimer: "", + email: { + placeHolder: "", + title: "" + } + } + } = content?.homePage ? content.homePage : {} + + useEffect(() => { + dispatch(headerActionCreators.updateHeader(header as HeaderModel)) + }, [header]) + + return <> - - - - - + + + + + } diff --git a/webapi/ClientApp/src/store/index.ts b/webapi/ClientApp/src/store/index.ts index b3d1a41..f96374c 100644 --- a/webapi/ClientApp/src/store/index.ts +++ b/webapi/ClientApp/src/store/index.ts @@ -4,6 +4,7 @@ import * as BlogFeatured from './reducers/BlogFeatured' import * as BlogItem from './reducers/BlogItem' import * as Counter from './reducers/Counter' +import * as Header from './reducers/Header' import * as Loader from './reducers/Loader' import * as Content from './reducers/Content' @@ -27,6 +28,7 @@ export interface ApplicationState { content: Content.ContentState | undefined counter: Counter.CounterState | undefined + header: Header.HeaderState | undefined loader: Loader.LoaderState | undefined shopCatalog: ShopCatalog.ShopCatalogState | undefined @@ -51,6 +53,7 @@ export const reducers = { content: Content.reducer, counter: Counter.reducer, + header: Header.reducer, loader: Loader.reducer, shopCatalog: ShopCatalog.reducer, diff --git a/webapi/ClientApp/src/store/reducers/Content.ts b/webapi/ClientApp/src/store/reducers/Content.ts index 7c060fa..6a04df4 100644 --- a/webapi/ClientApp/src/store/reducers/Content.ts +++ b/webapi/ClientApp/src/store/reducers/Content.ts @@ -1,5 +1,6 @@ import { Action, Reducer } from 'redux' import { AppThunkAction } from '../' +import { ReservedWords } from '../../enumerations' import { GetContentRequestModel } from '../../models/requests' import { GetContentResponseModel } from '../../models/responses' @@ -36,17 +37,15 @@ export const actionCreators = { const unloadedState: ContentState = { siteName: "MAKS-IT", + siteUrl: "https://maks-it.com", - helmet: { - title: "{siteName}", + header: { + title: `${ReservedWords.siteName}`, meta: { chartset: "utf-8", - description: "react-redux", - "google-site-verification": "", - robots: "noindex, nofollow" + "google-site-verification": "" }, link: { - canonical: "" } }, @@ -94,11 +93,20 @@ const unloadedState: ContentState = { { target: "/signin", title: "Sing in" }, { target: "/signup", title: "Sign up" }, - { target: "/shop/cart", icon: "shopping-cart", title: "Cart ({quantity})" } + { target: "/shop/cart", icon: "shopping-cart", title: `Cart (${ReservedWords.quantity})` } ], sideMenu: [], homePage: { + header: { + title: `Home - ${ReservedWords.siteName}`, + meta: { + description: "Single-page application home page", + }, + link: { + canonical: `${ReservedWords.siteUrl}` + } + }, titleSection: { title: "Hello, World! by Redux", text: `

Welcome to your new single-page application, built with:

@@ -156,6 +164,15 @@ const unloadedState: ContentState = { }, shopCatalog: { + header: { + title: `Shop catalog - ${ReservedWords.siteName}`, + meta: { + description: "Single-page application shop catalog", + }, + link: { + canonical: "" + } + }, titleSection: { title: "Shop in style", text: "With this shop hompeage template" @@ -166,6 +183,15 @@ const unloadedState: ContentState = { }, shopItem: { + header: { + title: `${ReservedWords.productTitle} - ${ReservedWords.siteName}`, + meta: { + description: "Single-page application shop item", + }, + link: { + canonical: "" + } + }, productSection: { availableQuantity: "Available Qty.", addToCart: "Add to cart" @@ -177,13 +203,22 @@ const unloadedState: ContentState = { }, shopCart: { + header: { + title: `Shop cart - ${ReservedWords.siteName}`, + meta: { + description: "Single-page application shop cart", + }, + link: { + canonical: "" + } + }, titleSection: { title: "Shopping Cart", text: "items in your cart" }, productsSection: { title: "Shopping Cart", - text: "{quantity} items in your cart", + text: `${ReservedWords.quantity} items in your cart`, product: "Product", price: "Price", quantity: "Quantity", @@ -199,6 +234,15 @@ const unloadedState: ContentState = { }, shopCheckout: { + header: { + title: `Shop - checkout ${ReservedWords.siteName}`, + meta: { + description: "Single-page application checkout", + }, + link: { + canonical: "" + } + }, titleSection: { title: "Checkout", text: "Below is an example form built entirely with Bootstrap’s form controls. Each required form group has a validation state that can be triggered by attempting to submit the form without completing it." @@ -281,7 +325,7 @@ const unloadedState: ContentState = { }, summarySection: { title: "Your cart", - total: "Total ({currency})", + total: `Total (${ReservedWords.currency})`, promoCode: { placeHolder: "Promo code" }, @@ -315,18 +359,36 @@ const unloadedState: ContentState = { }, blogCatalog: { + header: { + title: `Blog catalog - ${ReservedWords.siteName}`, + meta: { + description: "Single-page application blog catalog", + }, + link: { + canonical: "" + } + }, titleSection: { title: "Welcome to Blog Home!", text: "A Bootstrap 5 starter layout for your next blog homepage" }, featuredBlogSection: { - readTime: "{date} Time to read: {readTime} min" + readTime: `${ReservedWords.date} Time to read: ${ReservedWords.readTime} min` }, }, blogItem: { + header: { + title: `${ReservedWords.blogTitle} - ${ReservedWords.siteName}`, + meta: { + description: "Single-page application blog item", + }, + link: { + canonical: "" + } + }, titleSection: { - postedOnBy: "Posted on {date} by {nickName}" + postedOnBy: `Posted on ${ReservedWords.date} by ${ReservedWords.nickName}` }, commentsSection: { leaveComment: "Join the discussion and leave a comment!" @@ -335,6 +397,16 @@ const unloadedState: ContentState = { }, signIn: { + header: { + title: `Sign in - ${ReservedWords.siteName}`, + meta: { + description: "Single-page application sign in", + robots: "noindex, nofollow" + }, + link: { + canonical: "" + } + }, title: "Sign in", email: { title: "Email address", @@ -355,6 +427,16 @@ const unloadedState: ContentState = { }, signUp: { + header: { + title: "Sign up - {siteName}", + meta: { + description: "Single-page application sign up", + robots: "noindex, nofollow" + }, + link: { + canonical: "" + } + }, title: "Sign up", username: { title: "Username", diff --git a/webapi/ClientApp/src/store/reducers/Header.ts b/webapi/ClientApp/src/store/reducers/Header.ts new file mode 100644 index 0000000..fdf05e5 --- /dev/null +++ b/webapi/ClientApp/src/store/reducers/Header.ts @@ -0,0 +1,88 @@ +import { Action, Reducer } from 'redux' +import { AppThunkAction } from '..' +import { ReservedWords } from '../../enumerations' + +import { HeaderLink, HeaderModel } from '../../models' + +// ----------------- +// STATE - This defines the type of data maintained in the Redux store. + +export interface HeaderState extends HeaderModel {} + +// ----------------- +// ACTIONS - These are serializable (hence replayable) descriptions of state transitions. +// They do not themselves have any side-effects they just describe something that is going to happen. +// Use @typeName and isActionType for type detection that works even after serialization/deserialization. + +export interface ReceiveAction extends HeaderModel { type: 'RECEIVE_UPDATE_HEADER' } + + +// Declare a 'discriminated union' type. This guarantees that all references to 'type' properties contain one of the +// declared type strings (and not any other arbitrary string). +export type KnownAction = ReceiveAction + +// ---------------- +// ACTION CREATORS - These are functions exposed to UI components that will trigger a state transition. +// They don't directly mutate state, but they can have external side-effects (such as loading data). + +export const actionCreators = { + + updateHeader: (props: HeaderModel): AppThunkAction => (dispatch, getState) => { + + const siteName = getState().content?.siteName + const baseHeader = getState().content?.header + + const title = (props?.title + ? props.title + : baseHeader?.title + ? baseHeader.title + : ReservedWords.siteName).replace(ReservedWords.siteName, siteName ? siteName : "") + + // assign default link + const link = baseHeader?.link + ? baseHeader.link + : {} + + // assign default meta + const meta = baseHeader?.meta + ? baseHeader.meta + : {} + + // overrid link + if (props.link) + Object.keys(props.link).forEach(key => link[key] = props.link[key]) + + // override meta + if(props.meta) + Object.keys(props.meta).forEach(key => meta[key] = props.meta[key]) + + dispatch({ type: 'RECEIVE_UPDATE_HEADER', ...{ title, link, meta } }) + } +} + +const unloadedState: HeaderState = { + title: "", + link: {}, + meta: {} +} + + +// ---------------- +// REDUCER - For a given state and action, returns the new state. To support time travel, this must not mutate the old state. + +export const reducer: Reducer = (state: HeaderState | undefined, incomingAction: Action): HeaderState => { + if (state === undefined) { + return unloadedState + } + + const action = incomingAction as KnownAction + switch (action.type) { + case 'RECEIVE_UPDATE_HEADER': + return { + ...action + } + + default: + return state + } +}