diff --git a/.env.custom b/.env.custom new file mode 100644 index 0000000..594ee18 --- /dev/null +++ b/.env.custom @@ -0,0 +1,3 @@ +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken \ No newline at end of file diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..594ee18 --- /dev/null +++ b/.env.development @@ -0,0 +1,3 @@ +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..594ee18 --- /dev/null +++ b/.env.production @@ -0,0 +1,3 @@ +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken \ No newline at end of file diff --git a/SHADCN_UI_SETUP.md b/SHADCN_UI_SETUP.md index d0ed7e6..3924136 100644 --- a/SHADCN_UI_SETUP.md +++ b/SHADCN_UI_SETUP.md @@ -1,13 +1,12 @@ -# Shared UI Setup For Micro Frontend Application (Module Federation with React) with Nx Workspace +# Shared Data-Layer Setup For Micro Frontend Application with Nx Workspace -This tutorial will guide you through setting up a shared `UI library` for a `Micro Frontend Application` using Nx Workspace, React, and Tailwind CSS. We will use `Shadcn UI` for the UI components. +This tutorial will guide ## Link for Final Implementation The final implementation of the tutorial can be found in the following repository commits: -- [Add UI package with Shadcn components and use them on apps](https://github.com/serifcolakel/mfe-tutorial/commit/5704168095b2c83b8b51823ed585a1cdf3210dbc) -- [Add UI package with Button component and update dependencies](https://github.com/serifcolakel/mfe-tutorial/commit/cafa9a12f9c95a9a1536ff4e11c2a2008a3d89a5) +- > Live Demo: [Micro Frontend Application with Nx Workspace](https://relaxed-mochi-7581fa.netlify.app/) @@ -29,34 +28,37 @@ Before we begin, make sure you have the following things set up: ## Table of Contents -- [Create UI Library](#create-ui-library) -- [Add Tailwind CSS Setup](#add-tailwind-css-setup) -- [Shadcn UI Setup](#shadcn-ui-setup) - - [Add Button Component](#add-button-component) - - [Add Shadcn UI Hover Card](#add-shadcn-ui-hover-card) - - [Add Shadcn UI Badge](#add-shadcn-ui-badge) -- [Conclusion](#conclusion) +- [Create React Library](#create-react-library) -## Create UI Library +## Create React Library -First, we need to create a UI library using the Nx Workspace. We will use the `@nx/react:library` generator to create the UI library. +First, we need to create a React library using the Nx Workspace. We will use the `@nx/react:library` generator to create the React library. > With Script ```bash -pnpm exec nx generate @nx/react:library --name=ui --bundler=vite --directory=packages/ui --projectNameAndRootFormat=as-provided --no-interactive +pnpm exec nx generate @nx/react:library --name=data --bundler=vite --directory=apps/data --projectNameAndRootFormat=as-provided --no-interactive --dry-run ``` +The Scripts are explained below: + +- **--name** : The name of the library. In this case, we are naming it `data`. +- **--bundler** : The bundler to use for the library. In this case, we are using `vite`. +- **--directory** : The directory where the library will be created. In this case, we are creating it in the `apps/data` directory. +- **--projectNameAndRootFormat** : The format to use for the project name and root. In this case, we are using `as-provided`. +- **--no-interactive** : Disable interactive prompts. +- **--dry-run** : Show what will be generated without actually generating it. + > With Nx Console ![Nx Console](https://i.hizliresim.com/przb27y.png) -## Add Tailwind CSS Setup +## Add Services For Data Layer -Next, we need to add the Tailwind CSS setup to the UI library. We will use the `@nx/react:setup-tailwind` generator to add the Tailwind CSS setup. +Next, we need to add the services for the data layer in the `data` library. We will create a `data` service that fetches data from an API. ```bash -pnpm exec nx generate @nx/react:setup-tailwind --project=ui --no-interactive +pnpm add axios ``` - **Configure Tailwind Config** : Update the `packages/ui/tailwind.config.js` file with the following content: diff --git a/SHARED_DATA_SERVICE_LAYER_SETUP.md b/SHARED_DATA_SERVICE_LAYER_SETUP.md new file mode 100644 index 0000000..b4b1c59 --- /dev/null +++ b/SHARED_DATA_SERVICE_LAYER_SETUP.md @@ -0,0 +1,1742 @@ +# Shared Data-Layer Setup For Micro Frontend Application with Nx Workspace + +This tutorial will guide you through setting up a shared `data-layer` for a `Micro Frontend Application` using Nx Workspace, React, and Axios. We will create a shared `data-layer` in the Nx Workspace that will be used by all the projects in the workspace. The shared `data-layer` will contain the service layer for fetching data from the API using Axios. + +## Link for Final Implementation + +The final implementation of the tutorial can be found in the following repository commits: + +- [All Commits](https://github.com/serifcolakel/mfe-tutorial/pull/1/commits/83898b17ccbe6a72644a4989999d6d28d872075d) +- [Pull Request Link](https://github.com/serifcolakel/mfe-tutorial/pull/1) + +> Live Demo: [Micro Frontend Application with Nx Workspace](https://relaxed-mochi-7581fa.netlify.app/) + +## Prerequisites + +Before we begin, make sure you have the following things set up: + +- [Base Repository](https://javascript.plainenglish.io/creating-nx-workspace-with-eslint-prettier-and-husky-configuration-b5f4d2fcb914) for creating Nx Workspace with ESLint, Prettier, and Husky Configuration. +- [Building a Micro Frontend Architecture with Nx Workspace](https://medium.com/javascript-in-plain-english/building-a-micro-frontend-architecture-with-nx-workspace-c0fd9b6bf322) for creating a micro frontend architecture using Nx Workspace. +- [Shared Tailwind Setup For Micro Frontend Application with Nx Workspace](https://medium.com/javascript-in-plain-english/shared-tailwind-setup-for-micro-frontend-application-with-nx-workspace-0c02a3ca097d) +- [Shared UI Components For Micro Frontend Application with Nx Workspace](https://dev.to/serifcolakel/shared-ui-setup-for-micro-frontend-application-module-federation-with-react-with-nx-workspace-1p7c) +- [Nx Workspace](https://nx.dev/nx-api/react): Nx is a set of extensible dev tools for monorepos, which helps you develop like Google, Facebook, and Microsoft. +- [Nx Console](https://nx.dev/recipes/nx-console#nx-console): Nx Console is a Visual Studio Code extension that provides a UI for the Nx CLI. +- [React](https://reactjs.org/): A JavaScript library for building user interfaces. +- [Tailwind CSS](https://tailwindcss.com/): A utility-first CSS framework for rapidly building custom designs. +- [ESLint](https://eslint.org/): A pluggable and configurable linter tool for identifying and reporting on patterns in JavaScript. +- [Prettier](https://prettier.io/): An opinionated code formatter that enforces a consistent code style. +- [Netlify](https://www.netlify.com/): A platform that provides continuous deployment, serverless functions, and more. +- [Shadcn UI](https://ui.shadcn.com/docs): Beautifully designed components that you can copy and paste into your apps. Accessible. Customizable. Open Source. + +## Table of Contents + +- [Create React Library](#create-react-library) +- [Add Environment Configuration For All Project With](#add-environment-configuration-for-all-project-with) +- [Create Service API Layer with Axios](#create-service-api-layer-with-axios) +- [Create Platzi Store Service](#create-platzi-store-service) +- [Usage of Platzi Store Service](#usage-of-platzi-store-service) +- [Create Product Page with Custom Hooks](#create-product-page-with-custom-hooks) + +## Create React Library + +First, we need to create a React library using the Nx Workspace. We will use the `@nx/react:library` generator to create the React library. + +> With Script + +```bash +pnpm exec nx generate @nx/react:library --name=data --bundler=vite --directory=apps/data --projectNameAndRootFormat=as-provided --no-interactive --dry-run +``` + +The Scripts are explained below: + +- **--name** : The name of the library. In this case, we are naming it `data`. +- **--bundler** : The bundler to use for the library. In this case, we are using `vite`. +- **--directory** : The directory where the library will be created. In this case, we are creating it in the `apps/data` directory. +- **--projectNameAndRootFormat** : The format to use for the project name and root. In this case, we are using `as-provided`. +- **--no-interactive** : Disable interactive prompts. +- **--dry-run** : Show what will be generated without actually generating it. + +> With Nx Console + +![Nx Console](https://i.hizliresim.com/msgusy5.png) + +> After creating the library, we can fix the all `eslint` and `prettier` issues in the `data` library. + +## Add Environment Configuration For All Project With + +https://nx.dev/recipes/react/use-environment-variables-in-react#using-environment-variables-in-react-applications +https://nx.dev/recipes/tips-n-tricks/define-environment-variables +Next, we need to add the environment configuration for all projects in the Nx Workspace. We will create following environment files in the root directory of the Nx Workspace: + +- `.env.development` : Development environment configuration. +- `.env.production` : Production environment configuration. +- `.env.custom` : Custom environment configuration for dynamic configuration example. + +You can follow the steps below to add the environment configuration: + +- **Create Environment Files** : Create the following environment files in the root directory of the Nx Workspace: + +```bash +touch .env.development .env.production .env.custom +``` + +- **Add Environment Variables** : Add the environment variables to the environment files. You can define different variables for each environment. + +```bash +# .env.development +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken +``` + +```bash +# .env.production +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken +``` + +```bash +# .env.custom +NX_BASE_PLATZI_STORE_SERVICE_URL=https://api.escuelajs.co/api/v1 +NX_ACCESS_TOKEN_KEY=accessToken +NX_REFRESH_TOKEN_KEY=refreshToken +``` + +By default, Nx will load any environment variables [Reference](https://nx.dev/recipes/tips-n-tricks/define-environment-variables). + +By assigning distinct names to both configuration and mode, you can eliminate any potential conflicts that may arise during environment variable loading. Additionally, consider defining custom configurations in your Nx workspace, each with a corresponding mode option [Reference](https://nx.dev/recipes/react/use-environment-variables-in-react#using-environment-variables-in-react-applications).For example, you can create configurations like `development`, `production`, and `custom`, each with its respective mode set, like this: + +```json +// nx.json +"configurations": { + "development": { + // ...rest of the configuration + "mode": "development" + }, + "production": { + // ...rest of the configuration + "mode": "production" + }, + "custom": { + // ...rest of the configuration + "mode": "custom" + } +} +``` + +Then we can update the application to use the environment variables based on the configuration and mode. We can use the `process.env` object to access the environment variables in the application. + +```json +// apps/container/project.json +{ + "name": "container", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/container/src", + "projectType": "application", + "targets": { + "build": { + // ...rest of the configuration + "configurations": { + // ...rest of the configuration + "custom": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "webpackConfig": "apps/container/webpack.config.prod.ts" // Or You can create custom webpack config for custom "apps/container/webpack.config.custom.ts" + } + } + }, + "serve": { + // ...rest of the configuration + "configurations": { + "development": { + "buildTarget": "container:build:development" + }, + "production": { + "buildTarget": "container:build:production", + "hmr": false + }, + "custom": { + "buildTarget": "container:build:custom", + "hmr": false + } + } + }, + // ...rest of the configuration + "serve-static": { + // ...rest of the configuration + "configurations": { + "development": { + "buildTarget": "container:build:development" + }, + "production": { + "buildTarget": "container:build:production" + }, + "custom": { + "buildTarget": "container:build:custom" + } + } + } + // ...rest of the configuration + }, + "tags": [] +} +``` + +Same approach applied to the `info` repository. + +```json +{ + "name": "info", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/info/src", + "projectType": "application", + "targets": { + "build": { + // ...rest of the configuration + "configurations": { + // ...rest of the configuration + "custom": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "webpackConfig": "apps/info/webpack.config.prod.ts" // Or You can create custom webpack config for custom "apps/info/webpack.config.custom.ts" + } + } + }, + "serve": { + // ...rest of the configuration + "configurations": { + "development": { + "buildTarget": "info:build:development" + }, + "production": { + "buildTarget": "info:build:production", + "hmr": false + }, + "custom": { + "buildTarget": "info:build:custom", + "hmr": false + } + } + }, + // ...rest of the configuration + "serve-static": { + // ...rest of the configuration + "configurations": { + "development": { + "buildTarget": "info:build:development" + }, + "production": { + "buildTarget": "info:build:production" + }, + "custom": { + "buildTarget": "info:build:custom" + } + } + } + // ...rest of the configuration + }, + "tags": [] +} +``` + +Last step we can access the `type-safe` and validate the environment variables in the `data` library. We can use `zod` for the validation. + +```ts +// apps/data/src/common/enviroment.ts +import { z } from 'zod'; + +import { getEnvParams } from '../helpers/environment.helpers'; + +/** + * @description The environment schema for the container app. + */ +const envSchema = z.object({ + // INFO (serif) : NX_* Custom Environment variables + NX_BASE_PLATZI_STORE_SERVICE_URL: z.string(), + NX_ACCESS_TOKEN_KEY: z.string(), + NX_REFRESH_TOKEN_KEY: z.string(), + + // INFO (serif) : NX_* Base environment variables + NX_CLI_SET: z.string(), + NX_LOAD_DOT_ENV_FILES: z.string(), + NX_WORKSPACE_ROOT: z.string(), + NX_TERMINAL_OUTPUT_PATH: z.string(), + NX_STREAM_OUTPUT: z.string(), + NX_TASK_TARGET_PROJECT: z.string(), + NX_TASK_TARGET_TARGET: z.string(), + NX_TASK_TARGET_CONFIGURATION: z.string(), + NX_TASK_HASH: z.string(), +}); + +function initEnvironment() { + const [errors, env] = getEnvParams( + process.env as Record, + envSchema + ); + + if (errors) { + window.console.error(errors); + + throw new Error('Environment variables are not valid'); + } + + return env as z.infer; +} + +export { initEnvironment }; +``` + +- **Create Environment Helpers** : Create the `environment.helpers.ts` file in the `apps/data/src/helpers` directory with the following content: + +```ts +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { z } from 'zod'; + +/** + * @description Gets the parameters from the environment variables. + * @param {Record} env The environment variables. + * @param {z.ZodObject} schema The schema. + * @returns The errors and the data. + */ +export function getEnvParams( + env: Record, + schema: z.ZodObject +): [Record | null, z.infer | null] { + const data: Record = {}; + const errors: Record = {}; + + for (const key in schema.shape) { + if (Object.prototype.hasOwnProperty.call(schema.shape, key)) { + const value = env[key]; + + if (value === undefined) { + errors[key] = `ERROR (serif) : Missing required env var: ${key}`; + } else { + try { + data[key] = (schema.shape[key] as z.ZodTypeAny)?.parse(value); + } catch (error) { + let message = 'INFO (serif) : Invalid env var'; + + if (error instanceof z.ZodError) { + message = `ERROR (serif) : ${error.errors[0].message}`; + } else if (error instanceof Error) { + message = `ERROR (serif) : ${error.message}`; + } + + errors[key] = message; + } + } + } + } + + if (Object.keys(errors).length) { + return [errors, null]; + } + + return [null, data as z.infer]; +} +``` + +- **Export the Environment Variables** : Export the environment variables from the `data` library. + +```tsx +// apps/data/src/index.ts +export * from './common'; +// ...rest of the code +``` + +- **Usage of Environment Variables** : Use the environment variables in the `data` library. + +```tsx +// apps/data/src/common/index.ts +import { initEnvironment } from './environments'; + +export const ENV = initEnvironment(); +``` + +🎉 Congirulations. You can use `ENV` object to all project. Example: + +```tsx +import { ENV } from '@mfe-tutorial/data'; + +console.log(ENV.NX_BASE_PLATZI_STORE_SERVICE_URL); +``` + +## Create Service API Layer with Axios + +Next, we need to create a service layer in the `data` library. The service layer will be responsible for fetching data from the API. We will create a `PlatziStoreService` class that will have methods to fetch data from the Platzi Store API. + +- **Install Axios** : Install the `axios` package in the `data` library. + +```bash +pnpm add axios +``` + +- **Create Service Apis** : Create the `apis` folder in the `apps/data/src` directory. Then create the `base.api.ts` file in the `apis` folder with the following content: + +```ts +import axios from 'axios'; + +const api = axios; + +api.defaults.headers.post['Content-Type'] = 'application/json'; +api.defaults.headers.Accept = 'application/json'; +api.defaults.withCredentials = false; +api.defaults.timeout = 1000 * 60 * 2; // Two minutes + +export { api }; +``` + +- **Write Platzi Store Api** : Write the `platzi.store.api.ts` file in the `apis` folder with the following content: + +```ts +import { ENV } from '../common'; +import { + errorInterceptor, + requestInterceptor, + responseInterceptor, +} from '../lib/api.interceptors'; +import { api } from './base.api'; + +export const platziStoreApi = api.create({ + baseURL: ENV.NX_BASE_PLATZI_STORE_SERVICE_URL, +}); + +platziStoreApi.interceptors.request.use(requestInterceptor, (error) => + Promise.reject(error) +); + +platziStoreApi.interceptors.response.use(responseInterceptor, errorInterceptor); +``` + +- **Write Services/Apis Helpers** : Write the `helpers` folder in the `apps/data/src` directory. Then Write the `service.helpers.ts` file in the `helpers` folder with the following content: + +```ts +import { AxiosError } from 'axios'; +import { ZodError } from 'zod'; + +import { BaseServiceResponse } from '../types'; + +/** + * @description Handles the error response. + * @param {unknown} error - Error + * @param {string | undefined} message - Message + * @returns {BaseServiceResponse} The service response. + * @example + * const error = new Error('An error occurred.'); + * const result = handleErrorResponse(error); + * console.log(result); // { data: null, message: 'An error occurred.', success: false } + * @example + * const error = new AxiosError('An error occurred.'); + * const result = handleErrorResponse(error); + * console.log(result); // { data: null, message: 'An error occurred.', success: false } + */ +export const handleErrorResponse = ( + error: unknown, + message: string | undefined = 'Unknown error occurred.' +): BaseServiceResponse => { + let status: number | undefined; + + if (error instanceof Error) { + message = error.message; + status = 500; + } + + if (error instanceof AxiosError) { + message = error.message; + status = error.response?.status; + } + + if (error instanceof ZodError) { + const paths = error.errors.map((err) => err.path[1]); + const uniquePaths = [...new Set(paths)]; + + message = `Error in fields: ${uniquePaths.join(', ')}`; + + status = 400; + } + + return { + data: null, + message, + success: false, + status, + }; +}; + +/** + * @description Formats the message of a service response. + * @param {string} message The message to be formatted. + * @param {string[]} replacerValues The strings to replace the placeholders in message. + * @returns {string} The formatted message. + * @example + * const message = 'The {0} is {1}!'; + * const replace = ['answer', '42']; + * const result = getServiceResponseMessage(message, replace); + * console.log(result); // The answer is 42! + */ +export const getServiceResponseMessage = ( + message: string, + replacerValues?: string[] +): string => { + let result = message; + + if (replacerValues) { + replacerValues.forEach((item, index) => { + result = result.replace(`{${index}}`, item); + }); + } + + return result; +}; +``` + +- **Create Interceptors** : Create the `api.interceptors.ts` file in the `lib` folder with the following content: + +```ts +import { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +import { ENV } from '../common'; +import { handleErrorResponse } from '../helpers'; + +// TODO (serif) : handle request here +export const requestInterceptor = (config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem(ENV.NX_ACCESS_TOKEN_KEY); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}; + +// TODO (serif) : handle response here +export const responseInterceptor = (response: AxiosResponse) => response; + +// TODO (serif) : handle error response here +export const errorInterceptor = async (error: AxiosError) => + Promise.reject(handleErrorResponse(error)); +``` + +- **Export Apis** : Export the APIs from the `apis/index.ts` file. + +```ts +export { platziStoreApi } from './platzi.store.api'; + +export { api } from './base.api'; +``` + +## Create Platzi Store Service + +Next, we need to create a `PlatziStoreService` in the `data` library. The `services/platzi` will have methods to fetch data from the Platzi Store API. + +- **Create Platzi Store Service Base Methods** : Create the `platzi` file in the `services` folder. Then write the following content in the `services/platzi/methods.ts` file: + +```ts +import { AxiosRequestConfig, AxiosResponse } from 'axios'; + +import { platziStoreApi } from '../../apis'; + +/** + * @description Sends a GET request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +async function get( + url: string, + config?: AxiosRequestConfig +): Promise> { + const response = await platziStoreApi.get(url, config); + + return response; +} + +/** + * @description Sends a POST request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const post = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.post(url, data, config); + + return response; +}; + +/** + * @description Sends a PUT request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const put = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.put(url, data, config); + + return response; +}; + +/** + * @description Sends a PATCH request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const patch = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.patch(url, data, config); + + return response; +}; + +/** + * @description Sends a DELETE request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const remove = async ( + url: string, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.delete(url, config); + + return response; +}; + +const platziStoreApiMethods = { + get, + post, + put, + patch, + remove, +}; + +export { platziStoreApiMethods }; +``` + +This methods, base methods for the `PlatziStoreService`. We can use this methods in the other services. + +- **Add Platzi Store Constants** : Add the Platzi Store constants in the `services/platzi/constants.ts` file: + +```ts +/** + * @description PRODUCTS paths for the PLATZI STORE API service + */ +export const PLATZI_STORE_PRODUCTS_PATHS = { + PRODUCT: { + GET_ALL: '/products', + GET_SINGLE: '/products/:id', + CREATE: '/products', + UPDATE: '/products/:id', + DELETE: '/products/:id', + }, + AUTH: { + LOGIN: '/auth/login', + PROFILE: '/auth/profile', + REFRESH_TOKEN: '/auth/refresh-token', + }, +}; +``` + +This constants, base constants for the `PlatziStoreService`. We can use this constants in the other services. + +- **Create Platzi Store Auth Services** : The `services/platzi/auth/` file with the following content: + +> - `services/platzi/auth/types.ts` file: The types for the Platzi Store Auth services. + +```ts +import { z } from 'zod'; + +import { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +} from './schemas'; + +export type LoginRequest = z.infer; + +export type LoginResponse = z.infer; + +export type UserProfileResponse = z.infer; + +export type RefreshTokenRequest = z.infer; + +export type RefreshTokenResponse = z.infer; +``` + +> - `services/platzi/auth/schemas.ts` file: The schemas for the Platzi Store Auth services. + +```ts +import { z } from 'zod'; + +export const loginRequestSchema = z.object({ + email: z.string().email('Please enter a valid email'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}); + +export const loginResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), +}); + +export const userProfileResponseSchema = z.object({ + id: z.number(), + email: z.string(), + password: z.string(), + name: z.string(), + role: z.string(), + avatar: z.string(), +}); + +export const refreshTokenRequestSchema = z.object({ refreshToken: z.string() }); + +export const refreshTokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), +}); +``` + +> - `services/platzi/auth/index.ts` file: The index file for the Platzi Store Auth services. + +```ts +import { handleErrorResponse } from '../../../helpers'; +import { BaseServiceResponse } from '../../../types'; +import { PLATZI_STORE_PRODUCTS_PATHS } from '../contants'; +import { platziStoreApiMethods as methods } from '../methods'; +import { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +} from './schemas'; +import { + LoginRequest, + LoginResponse, + RefreshTokenRequest, + RefreshTokenResponse, + UserProfileResponse, +} from './types'; + +/** + * @description Logs a user in. + * @param {LoginRequest} info The user to log in. + * @returns {Promise>} A Promise that resolves to a LoginResponse. + */ +export const login = async ( + info: LoginRequest +): Promise> => { + try { + const infos = loginRequestSchema.parse(info); + + const response = await methods.post( + PLATZI_STORE_PRODUCTS_PATHS.AUTH.LOGIN, + infos + ); + + const data = loginResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Gets the user profile. + * @returns {Promise>} A Promise that resolves to a UserProfileResponse. + */ +export const getUserProfile = async (): Promise< + BaseServiceResponse +> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.AUTH.PROFILE + ); + + const data = userProfileResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Refreshes the token. + * @param {RefreshTokenRequest} refreshToken The refresh token. + * @returns {Promise>} A Promise that resolves to a RefreshTokenResponse. + */ +export const refreshToken = async ( + token: RefreshTokenRequest +): Promise> => { + try { + const values = refreshTokenRequestSchema.parse(token); + + const response = await methods.post< + RefreshTokenRequest, + RefreshTokenResponse + >(PLATZI_STORE_PRODUCTS_PATHS.AUTH.REFRESH_TOKEN, values); + + const data = refreshTokenResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +export type { + LoginRequest, + LoginResponse, + UserProfileResponse, + RefreshTokenRequest, + RefreshTokenResponse, +}; + +export { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +}; +``` + +- **Create Platzi Store Products Services** : The `services/platzi/products/` file with the following content: + +> - `services/platzi/products/types.ts` file: The types for the Platzi Store Products services. + +```ts +import { z } from 'zod'; + +import { + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +} from './schemas'; + +export type Product = z.infer; + +export type CreateProductRequest = z.infer; + +export type CreateProductResponse = z.infer; + +export type UpdateProductRequest = z.infer; + +export type UpdateProductResponse = z.infer; + +export type DeleteProductResponse = boolean; +``` + +> - `services/platzi/products/schemas.ts` file: The schemas for the Platzi Store Products services. + +```ts +import { z } from 'zod'; + +export const productSchema = z.object({ + id: z.number(), + title: z.string(), + price: z.number(), + description: z.string(), + category: z.object({ id: z.number(), name: z.string(), image: z.string() }), + images: z.array(z.string()), +}); + +export const allProductsResponseSchema = z.array(productSchema); + +export const createProductRequestSchema = z.object({ + title: z.string(), + price: z.number(), + description: z.string(), + categoryId: z.number(), + images: z.array(z.string()), +}); + +export const createProductResponseSchema = z.object({ + title: z.string(), + price: z.number(), + description: z.string(), + images: z.array(z.string()), + category: z.object({ + id: z.number(), + name: z.string(), + image: z.string(), + creationAt: z.string(), + updatedAt: z.string(), + }), + id: z.number(), + creationAt: z.string(), + updatedAt: z.string(), +}); + +export const updateProductRequestSchema = z.object({ + title: z.string(), + price: z.number(), +}); + +export const updateProductResponseSchema = z.object({ + id: z.number(), + title: z.string(), + price: z.number(), + description: z.string(), + images: z.array(z.string()), + creationAt: z.string(), + updatedAt: z.string(), + category: z.object({ + id: z.number(), + name: z.string(), + image: z.string(), + creationAt: z.string(), + updatedAt: z.string(), + }), +}); +``` + +> - `services/platzi/products/index.ts` file: The index file for the Platzi Store Products services. + +```ts +import { handleErrorResponse } from '../../../helpers'; +import { BaseServiceResponse } from '../../../types'; +import { PLATZI_STORE_PRODUCTS_PATHS } from '../contants'; +import { platziStoreApiMethods as methods } from '../methods'; +import { + allProductsResponseSchema, + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +} from './schemas'; +import { + CreateProductRequest, + CreateProductResponse, + Product, + UpdateProductRequest, + UpdateProductResponse, +} from './types'; + +/** + * @description Gets all products from the API. + * @returns {Promise>} A Promise that resolves to an array of Post. + */ +export const getProducts = async (): Promise< + BaseServiceResponse +> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.GET_ALL, + { + params: { + limit: 10, + offset: 1, + }, + } + ); + + const data = allProductsResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Gets a single product from the API. + * @param {string} id The product ID. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const getProduct = async ( + id: string +): Promise> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.GET_SINGLE.replace(':id', id) + ); + + const data = productSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Creates a new product. + * @param {CreateProductRequest} product The product to create. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const createProduct = async ( + product: CreateProductRequest +): Promise> => { + try { + const values = createProductRequestSchema.parse(product); + + const response = await methods.post< + CreateProductRequest, + CreateProductResponse + >(PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.CREATE, values); + + const data = createProductResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Updates a product. + * @param {string} id The product ID. + * @param {UpdateProductRequest} product The product to update. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const updateProduct = async ( + id: string, + product: UpdateProductRequest +): Promise> => { + try { + const values = updateProductRequestSchema.parse(product); + + const response = await methods.put< + UpdateProductRequest, + UpdateProductResponse + >(PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.UPDATE.replace(':id', id), values); + + const data = updateProductResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Deletes a product. + * @param {string} id The product ID. + * @returns {Promise>} A Promise that resolves to null. + */ +export const deleteProduct = async ( + id: string +): Promise> => { + try { + const res = await methods.remove( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.DELETE.replace(':id', id) + ); + + return { + data: res.data, + message: 'Product deleted successfully.', + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +export type { + CreateProductRequest, + CreateProductResponse, + Product, + UpdateProductRequest, + UpdateProductResponse, +}; + +export { + allProductsResponseSchema, + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +}; +``` + +- **Export Platzi Store Services** : Export the services from the `services/platzi/index.ts` file. + +```ts +export * from './products'; +export * from './auth'; +export * from './methods'; +export * from './constants'; +``` + +- **Export Services** : Export the services from the `services/index.ts` file. + +```ts +export * from './platzi'; +``` + +- **Export Data** : Export the services from the `data` library in the `apps/data/src/index.ts` file. + +```ts +// ...rest of the code +export * from './services'; +``` + +## Usage of Platzi Store Service + +You can use directly from the service function or you can create custom hook for services with `loading`, `error`, `data` states. + +- **Create Custom Hook for Auth Service** : Create the `usePlatziStoreAuth` hook in the `apps/container/src/hooks/use-platzi-store-auth/index.ts` directory with the following content: + +```ts +import { useState } from 'react'; + +import { ENV } from '../../common'; +import { login, LoginRequest, refreshToken } from '../../services'; + +export function usePlatziStoreAuth() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogin = async (info: LoginRequest) => { + setLoading(true); + + const response = await login(info); + + const result = { + success: false, + message: 'Please check your email and password and try again.', + title: 'Login Failed', + }; + + if (response.success && response.data) { + localStorage.setItem(ENV.NX_ACCESS_TOKEN_KEY, response.data.access_token); + localStorage.setItem( + ENV.NX_REFRESH_TOKEN_KEY, + response.data.refresh_token + ); + + result.success = true; + result.message = 'You have successfully logged in!'; + result.title = 'Login Success'; + } else { + setError('Please check your email and password and try again.'); + } + + setLoading(false); + + return result; + }; + + const handleRefreshToken = async () => { + const token = localStorage.getItem(ENV.NX_REFRESH_TOKEN_KEY); + + if (token) { + const response = await refreshToken({ refreshToken: token }); + + if (response.success && response.data) { + localStorage.setItem( + ENV.NX_ACCESS_TOKEN_KEY, + response.data.access_token + ); + localStorage.setItem( + ENV.NX_REFRESH_TOKEN_KEY, + response.data.refresh_token + ); + } else { + setError(response.message); + } + } + }; + + const onResetError = () => setError(null); + + return { + loading, + error, + handleRefreshToken, + handleLogin, + onResetError, + }; +} +``` + +- **Create Custom Hook for Products Service** : Create the `usePlatziStoreProducts` hook in the `apps/container/src/hooks/use-platzi-store-products/index.ts` directory with the following content: + +```ts +import { useEffect, useState } from 'react'; + +import { + createProduct, + CreateProductRequest, + deleteProduct, + getProduct, + getProducts, + Product, + updateProduct, + UpdateProductRequest, +} from '../../services'; + +export type ProductError = { + message: string; + title: string; +}; + +export type Data = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; error: ProductError } + | { status: 'hasData'; data: Product[]; message?: string } + | { status: 'hasSingleData'; data: Product }; + +export default function usePlatziStoreProducts(fetchOnMount = true) { + const [data, setData] = useState({ status: 'idle' }); + + const fetchProducts = async (message?: string) => { + if (data.status !== 'loading') { + setData({ status: 'loading' }); + } + + const response = await getProducts(); + + if (response.success && response.data) { + setData({ status: 'hasData', data: response.data, message }); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Products Fetch Failed', + }, + }); + } + }; + + const fetchProduct = async (id: string) => { + setData({ status: 'loading' }); + + const response = await getProduct(id); + + if (response.success && response.data) { + setData({ status: 'hasSingleData', data: response.data }); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Fetch Failed', + }, + }); + } + }; + + const create = async ( + product: CreateProductRequest, + canGetProducts = true + ) => { + setData({ status: 'loading' }); + const response = await createProduct(product); + + if (response.success && response.data && canGetProducts) { + await fetchProducts('Product created successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Creation Failed', + }, + }); + } + + if (response.success && data.status === 'loading') { + setData({ status: 'idle' }); + } + }; + + const update = async ( + id: string, + product: UpdateProductRequest, + canGetProducts = true + ) => { + setData({ status: 'loading' }); + const response = await updateProduct(id, product); + + if ((response.success && response.data, canGetProducts)) { + await fetchProducts('Product updated successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Update Failed', + }, + }); + } + }; + + const remove = async (id: string) => { + setData({ status: 'loading' }); + const response = await deleteProduct(id); + + if (response.success && response.data) { + await fetchProducts('Product deleted successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Deletion Failed', + }, + }); + } + + if (response.success && data.status === 'loading') { + setData({ status: 'idle' }); + } + }; + + const hasDataMessage = data.status === 'hasData' ? !!data.message : false; + + useEffect(() => { + if (hasDataMessage) { + const timeout = setTimeout(() => { + setData((prev) => ({ + ...prev, + message: undefined, + })); + }, 3000); + + return () => clearTimeout(timeout); + } + }, [hasDataMessage]); + + useEffect(() => { + if (fetchOnMount) { + fetchProducts(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchOnMount]); + + return { + fetchProducts, + fetchProduct, + create, + update, + remove, + data, + }; +} +``` + +- **Export Custom Hooks** : Export the custom hooks from the `apps/data/src/hooks/index.ts` file. + +```ts +// ...rest of the code +export * from './use-platzi-store-auth'; +export * from './use-platzi-store-products'; +``` + +- **Usage of Custom Hooks** : + +Use the custom hooks in the `apps/container/src/pages/login/hooks/use-login.ts` file. + +```ts +import { zodResolver } from '@hookform/resolvers/zod'; +import { + LoginRequest, + loginRequestSchema, + paths, + usePlatziStoreAuth, +} from '@mfe-tutorial/data'; +import { useToast } from '@mfe-tutorial/ui'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +export default function useLogin() { + const navigate = useNavigate(); + const { toast } = useToast(); + const { error, handleLogin, loading } = usePlatziStoreAuth(); + + const loginForm = useForm({ + defaultValues: { + email: 'john@mail.com', + password: 'changeme', + }, + resolver: zodResolver(loginRequestSchema), + }); + + async function onSubmit(data: LoginRequest) { + const result = await handleLogin(data); + + toast({ + title: result.title, + description: result.message, + variant: result.success ? 'default' : 'destructive', + }); + + if (result.success) { + navigate(paths.info); + } + } + + return { + loginForm, + loading: + loading || + loginForm.formState.isLoading || + loginForm.formState.isSubmitting, + error, + onSubmit, + }; +} +``` + +Render the `useLogin` hook in the `apps/container/src/pages/login/index.tsx` file. + +```tsx +import { + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Form, + InputField, +} from '@mfe-tutorial/ui'; + +import useLogin from './hooks/use-login'; + +export default function LoginPage() { + const { loginForm, onSubmit, loading, error, onResetError } = useLogin(); + + if (error) { + return ( +
+

An error occurred!

+

{error}

+ +
+ ); + } + + return ( +
+ + + + Login + + Please enter your email and password to login. + + + + + + + + + + +
+ + ); +} +``` + +## Create Product Page with Custom Hooks + +Create the `apps/info/src/app/app.tsx` file with the following content: + +```tsx +import { Product } from '@mfe-tutorial/data'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + Label, +} from '@mfe-tutorial/ui'; +import { Loader, Plus, RefreshCcwIcon, Trash } from 'lucide-react'; +import usePlatziStoreProducts from 'packages/data/src/hooks/use-platzi-store-products'; + +const getFormattedAmount = (amount: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); + +function ProductCarousel({ images }: { images: Product['images'] }) { + return ( + + + {images.map((image) => ( + + {image} + + ))} + + + + + ); +} + +function ProductCard({ + product, + children, +}: { + product: Product; + children?: React.ReactNode; +}) { + return ( + + + + {product.title} + {product.description} + + + + + + + {product.category.name} + {children} + + + ); +} + +function CreateProductButton({ callback }: { callback: () => void }) { + return ( + + ); +} + +export function App() { + const { create, data, fetchProduct, fetchProducts, remove, update } = + usePlatziStoreProducts(); + + if (data.status === 'loading') { + return ( +
+ + Loading... +
+ ); + } + + if (data.status === 'error') { + return ( +
+

An error occurred!

+

{data.error.message}

+ +
+ ); + } + + const renderContent = () => { + if (data.status === 'hasData') { + const { data: products, message } = data; + + return ( +
+ {message && ( + + {message} + + )} +
    + {products.map((product) => ( +
  • + +
    + + + +
    +
    +
  • + ))} +
+
+ ); + } + + if (data.status === 'hasSingleData') { + const { data: product } = data; + + return ( +
+ + + +
+ ); + } + + return ( +
+

No products found!

+ +
+ ); + }; + + return ( +
+
+

Platzi Store

+ { + const newProduct = { + title: 'New Product', + description: 'This is a new product.', + price: 100, + categoryId: 1, + images: ['https://via.placeholder.com/300'], + }; + + await create(newProduct); + }} + /> +
+ {renderContent()} +
+ ); +} + +export default App; +``` + +## Conclusion + +In this tutorial, we learned how to set up a shared Data Layer for a Micro Frontend Application using Nx Workspace, React, and Tailwind CSS. We created a shared `services` library to manage the API services and a shared `hooks` library to manage the custom hooks for the services. We also created custom hooks for the `Platzi Store Auth` and `Platzi Store Products` services and used them in the `Login` and `Product` pages. + +The shared Data Layer allows us to manage the API services and custom hooks in a single place and reuse them across multiple applications. This helps to keep the codebase clean, maintainable, and scalable. By following this approach, we can easily add new services, custom hooks, and features to our applications without duplicating code. + +I hope you found this tutorial helpful and that you can now integrate Shadcn UI, a beautifully designed component library, into your projects. Happy coding! 🎉 diff --git a/apps/container/module-federation.config.ts b/apps/container/module-federation.config.ts index 0c3d17e..c9c4ba6 100644 --- a/apps/container/module-federation.config.ts +++ b/apps/container/module-federation.config.ts @@ -3,6 +3,90 @@ import { ModuleFederationConfig } from '@nx/webpack'; const config: ModuleFederationConfig = { name: 'container', remotes: ['info'], + shared: (name, defaultConfig) => { + // "react-hook-form": "^7.51.3" + if (name.includes('react-hook-form')) { + return { + singleton: true, + eager: true, + requiredVersion: '^7.51.3', + }; + } + + // "@hookform/resolvers": "^3.3.4" + if (name.includes('@hookform/resolvers')) { + return { + ...defaultConfig, + strictVersion: false, + requiredVersion: '^3.3.4', + }; + } + + // "zod": "^3.22.5" + if (name.includes('zod')) { + return { + singleton: true, + eager: true, + requiredVersion: '^3.22.5', + }; + } + + // react 18.2.0 + if (name.includes('react')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // react-dom 18.2.0 + if (name.includes('react-dom')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // "react-redux": "^9.1.1", + if (name.includes('react-redux')) { + return { + singleton: true, + eager: true, + requiredVersion: '^9.1.1', + }; + } + + // "@reduxjs/toolkit": "^2.2.3", + if (name.includes('@reduxjs/toolkit')) { + return { + singleton: true, + eager: true, + requiredVersion: '^2.2.3', + }; + } + + // @radix-ui/react-toast (required ^1.1.5) + if (name.includes('@radix-ui/react-toast')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.1.5', + }; + } + + // @radix-ui/react-slot (required ^1.0.2) + if (name.includes('@radix-ui/react-slot')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.0.2', + }; + } + + return false; + }, }; export default config; diff --git a/apps/container/project.json b/apps/container/project.json index 8e4c082..377d902 100644 --- a/apps/container/project.json +++ b/apps/container/project.json @@ -46,6 +46,15 @@ "extractLicenses": true, "vendorChunk": false, "webpackConfig": "apps/container/webpack.config.prod.ts" + }, + "custom": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "webpackConfig": "apps/container/webpack.config.custom.ts" } } }, @@ -64,6 +73,10 @@ "production": { "buildTarget": "container:build:production", "hmr": false + }, + "custom": { + "buildTarget": "container:build:custom", + "hmr": false } } }, @@ -88,6 +101,9 @@ }, "production": { "buildTarget": "container:build:production" + }, + "custom": { + "buildTarget": "container:build:custom" } } }, diff --git a/apps/container/src/app/app.tsx b/apps/container/src/app/app.tsx index 2178f19..d3cdd0e 100644 --- a/apps/container/src/app/app.tsx +++ b/apps/container/src/app/app.tsx @@ -1,55 +1,14 @@ -import { Button } from '@mfe-tutorial/ui'; -import * as React from 'react'; -import { NavLink, Route, Routes } from 'react-router-dom'; +import { Loader } from 'lucide-react'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -const HomePage = React.lazy(() => import('../pages/home')); -const Info = React.lazy(() => import('info/InfoContainer')); +import { routes } from '../routes'; export function App() { return ( - - - - } path="/" /> - } path="/info" /> - - + } + router={createBrowserRouter(routes)} + /> ); } diff --git a/apps/container/src/bootstrap.tsx b/apps/container/src/bootstrap.tsx index 6043c7d..1a7f731 100644 --- a/apps/container/src/bootstrap.tsx +++ b/apps/container/src/bootstrap.tsx @@ -1,6 +1,7 @@ +import { DataLayerProviders } from '@mfe-tutorial/data'; +import { Toaster } from '@mfe-tutorial/ui'; import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; import App from './app/app'; @@ -14,8 +15,9 @@ const root = ReactDOM.createRoot(element); root.render( - + + - + ); diff --git a/apps/container/src/pages/home/index.tsx b/apps/container/src/pages/home/index.tsx index 5c99cfd..75db848 100644 --- a/apps/container/src/pages/home/index.tsx +++ b/apps/container/src/pages/home/index.tsx @@ -1,3 +1,6 @@ +import { paths } from '@mfe-tutorial/data'; +import { NavLink } from 'react-router-dom'; + import { HoverCardDemo } from '../../components/hover-card'; import SocialLinks from '../../components/social-links'; @@ -5,6 +8,9 @@ export default function HomePage() { return (

🌍

+ + Go to the Login App +

Welcome to the Container!

diff --git a/apps/container/src/pages/login/hooks/use-login.ts b/apps/container/src/pages/login/hooks/use-login.ts new file mode 100644 index 0000000..89f9fcb --- /dev/null +++ b/apps/container/src/pages/login/hooks/use-login.ts @@ -0,0 +1,49 @@ +import { zodResolver } from '@hookform/resolvers/zod'; +import { + LoginRequest, + loginRequestSchema, + paths, + usePlatziStoreAuth, +} from '@mfe-tutorial/data'; +import { useToast } from '@mfe-tutorial/ui'; +import { useForm } from 'react-hook-form'; +import { useNavigate } from 'react-router-dom'; + +export default function useLogin() { + const navigate = useNavigate(); + const { toast } = useToast(); + const { error, handleLogin, loading, onResetError } = usePlatziStoreAuth(); + + const loginForm = useForm({ + defaultValues: { + email: 'john@mail.com', + password: 'changeme', + }, + resolver: zodResolver(loginRequestSchema), + }); + + async function onSubmit(data: LoginRequest) { + const result = await handleLogin(data); + + toast({ + title: result.title, + description: result.message, + variant: result.success ? 'default' : 'destructive', + }); + + if (result.success) { + navigate(paths.info); + } + } + + return { + loginForm, + loading: + loading || + loginForm.formState.isLoading || + loginForm.formState.isSubmitting, + error, + onSubmit, + onResetError, + }; +} diff --git a/apps/container/src/pages/login/index.tsx b/apps/container/src/pages/login/index.tsx new file mode 100644 index 0000000..ba739df --- /dev/null +++ b/apps/container/src/pages/login/index.tsx @@ -0,0 +1,65 @@ +import { + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Form, + InputField, +} from '@mfe-tutorial/ui'; + +import useLogin from './hooks/use-login'; + +export default function LoginPage() { + const { loginForm, onSubmit, loading, error, onResetError } = useLogin(); + + if (error) { + return ( +
+

An error occurred!

+

{error}

+ +
+ ); + } + + return ( +
+ + + + Login + + Please enter your email and password to login. + + + + + + + + + + +
+ + ); +} diff --git a/apps/container/src/routes/index.tsx b/apps/container/src/routes/index.tsx new file mode 100644 index 0000000..7a04ad0 --- /dev/null +++ b/apps/container/src/routes/index.tsx @@ -0,0 +1,23 @@ +import { paths } from '@mfe-tutorial/data'; +import { withSuspense } from '@mfe-tutorial/ui'; +import { lazy } from 'react'; +import { RouteObject } from 'react-router-dom'; + +const HomePage = withSuspense(lazy(() => import('../pages/home'))); +const LoginPage = withSuspense(lazy(() => import('../pages/login'))); +const Info = withSuspense(lazy(() => import('info/InfoContainer'))); + +export const routes: RouteObject[] = [ + { + path: paths.home, + element: , + }, + { + path: paths.login, + element: , + }, + { + path: paths.info, + element: , + }, +]; diff --git a/apps/container/webpack.config.custom.ts b/apps/container/webpack.config.custom.ts new file mode 100644 index 0000000..eb1885b --- /dev/null +++ b/apps/container/webpack.config.custom.ts @@ -0,0 +1,34 @@ +import { withReact } from '@nx/react'; +import { withModuleFederation } from '@nx/react/module-federation'; +import { composePlugins, ModuleFederationConfig, withNx } from '@nx/webpack'; + +import baseConfig from './module-federation.config'; + +const prodConfig: ModuleFederationConfig = { + ...baseConfig, + /* + * Remote overrides for production. + * Each entry is a pair of a unique name and the URL where it is deployed. + * + * e.g. + * remotes: [ + * ['app1', 'http://app1.example.com'], + * ['app2', 'http://app2.example.com'], + * ] + * + * You can also use a full path to the remoteEntry.js file if desired. + * + * remotes: [ + * ['app1', 'http://example.com/path/to/app1/remoteEntry.js'], + * ['app2', 'http://example.com/path/to/app2/remoteEntry.js'], + * ] + */ + remotes: [['info', 'https://animated-lollipop-8d4ee8.netlify.app/']], +}; + +// Nx plugins for webpack to build config object from Nx options and context. +export default composePlugins( + withNx(), + withReact(), + withModuleFederation(prodConfig) +); diff --git a/apps/info/module-federation.config.ts b/apps/info/module-federation.config.ts index 955bd86..3ff020e 100644 --- a/apps/info/module-federation.config.ts +++ b/apps/info/module-federation.config.ts @@ -5,6 +5,90 @@ const config: ModuleFederationConfig = { exposes: { './InfoContainer': './src/app/app', }, + shared: (name, defaultConfig) => { + // "react-hook-form": "^7.51.3" + if (name.includes('react-hook-form')) { + return { + singleton: true, + eager: true, + requiredVersion: '^7.51.3', + }; + } + + // "@hookform/resolvers": "^3.3.4" + if (name.includes('@hookform/resolvers')) { + return { + ...defaultConfig, + strictVersion: false, + requiredVersion: '^3.3.4', + }; + } + + // "zod": "^3.22.5" + if (name.includes('zod')) { + return { + singleton: true, + eager: true, + requiredVersion: '^3.22.5', + }; + } + + // react 18.2.0 + if (name.includes('react')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // react-dom 18.2.0 + if (name.includes('react-dom')) { + return { + singleton: true, + eager: true, + requiredVersion: '^18.2.0', + }; + } + + // "react-redux": "^9.1.1", + if (name.includes('react-redux')) { + return { + singleton: true, + eager: true, + requiredVersion: '^9.1.1', + }; + } + + // "@reduxjs/toolkit": "^2.2.3", + if (name.includes('@reduxjs/toolkit')) { + return { + singleton: true, + eager: true, + requiredVersion: '^2.2.3', + }; + } + + // @radix-ui/react-toast (required ^1.1.5) + if (name.includes('@radix-ui/react-toast')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.1.5', + }; + } + + // @radix-ui/react-slot (required ^1.0.2) + if (name.includes('@radix-ui/react-slot')) { + return { + singleton: true, + eager: true, + requiredVersion: '^1.0.2', + }; + } + + return false; + }, }; export default config; diff --git a/apps/info/project.json b/apps/info/project.json index a48a235..b341af2 100644 --- a/apps/info/project.json +++ b/apps/info/project.json @@ -43,6 +43,15 @@ "extractLicenses": true, "vendorChunk": false, "webpackConfig": "apps/info/webpack.config.prod.ts" + }, + "custom": { + "optimization": true, + "outputHashing": "all", + "sourceMap": false, + "namedChunks": false, + "extractLicenses": true, + "vendorChunk": false, + "webpackConfig": "apps/info/webpack.config.custom.ts" } } }, @@ -61,6 +70,10 @@ "production": { "buildTarget": "info:build:production", "hmr": false + }, + "custom": { + "buildTarget": "info:build:custom", + "hmr": false } } }, @@ -85,6 +98,9 @@ }, "production": { "buildTarget": "info:build:production" + }, + "custom": { + "buildTarget": "info:build:custom" } } }, diff --git a/apps/info/src/app/app.tsx b/apps/info/src/app/app.tsx index 49a670a..5bbec27 100644 --- a/apps/info/src/app/app.tsx +++ b/apps/info/src/app/app.tsx @@ -1,20 +1,211 @@ -import { ContextMenuDemo } from '../components/context-menu'; +import { Product } from '@mfe-tutorial/data'; +import { + Badge, + Button, + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, + Label, +} from '@mfe-tutorial/ui'; +import { Loader, Plus, RefreshCcwIcon, Trash } from 'lucide-react'; +import usePlatziStoreProducts from 'packages/data/src/hooks/use-platzi-store-products'; + +const getFormattedAmount = (amount: number) => + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: 'USD', + }).format(amount); + +function ProductCarousel({ images }: { images: Product['images'] }) { + return ( + + + {images.map((image) => ( + + {image} + + ))} + + + + + ); +} + +function ProductCard({ + product, + children, +}: { + product: Product; + children?: React.ReactNode; +}) { + return ( + + + + {product.title} + {product.description} + + + + + + + {product.category.name} + {children} + + + ); +} + +function CreateProductButton({ callback }: { callback: () => void }) { + return ( + + ); +} export function App() { + const { create, data, fetchProduct, fetchProducts, remove, update } = + usePlatziStoreProducts(); + + if (data.status === 'loading') { + return ( +
+ + Loading... +
+ ); + } + + if (data.status === 'error') { + return ( +
+

An error occurred!

+

{data.error.message}

+ +
+ ); + } + + const renderContent = () => { + if (data.status === 'hasData') { + const { data: products, message } = data; + + return ( +
+ {message && ( + + {message} + + )} +
    + {products.map((product) => ( +
  • + +
    + + + +
    +
    +
  • + ))} +
+
+ ); + } + + if (data.status === 'hasSingleData') { + const { data: product } = data; + + return ( +
+ + + +
+ ); + } + + return ( +
+

No products found!

+ +
+ ); + }; + return ( -
-

Welcome to info!

-

This is a remote app that is part of the Nx plugin for Webpack 5.

-
-

-

Info

-

-

- This app is a remote app that is part of the Nx plugin for Webpack 5. -

-
- -
+
+
+

Platzi Store

+ { + const newProduct = { + title: 'New Product', + description: 'This is a new product.', + price: 100, + categoryId: 1, + images: ['https://via.placeholder.com/300'], + }; + + await create(newProduct); + }} + /> +
+ {renderContent()} +
); } diff --git a/apps/info/src/components/counter-actions/index.tsx b/apps/info/src/components/counter-actions/index.tsx new file mode 100644 index 0000000..7d72184 --- /dev/null +++ b/apps/info/src/components/counter-actions/index.tsx @@ -0,0 +1,21 @@ +import { useCounter } from '@mfe-tutorial/data'; +import { Button } from '@mfe-tutorial/ui'; +import React from 'react'; + +export default function CounterActions() { + const { + counterValue, + handleDecrement, + handleIncrement, + handleIncrementByAmount, + } = useCounter(); + + return ( +
+

Counter: {counterValue}

+ + + +
+ ); +} diff --git a/apps/info/webpack.config.custom.ts b/apps/info/webpack.config.custom.ts new file mode 100644 index 0000000..de3e8aa --- /dev/null +++ b/apps/info/webpack.config.custom.ts @@ -0,0 +1,3 @@ +import webPackConfig from './webpack.config'; + +export default webPackConfig; diff --git a/package.json b/package.json index f968ce5..7f6d68e 100644 --- a/package.json +++ b/package.json @@ -12,18 +12,32 @@ }, "private": true, "dependencies": { + "@hookform/resolvers": "^3.3.4", "@radix-ui/react-context-menu": "^2.1.5", "@radix-ui/react-hover-card": "^1.0.7", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-popover": "^1.0.7", + "@radix-ui/react-select": "^2.0.0", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", + "@radix-ui/react-tooltip": "^1.0.7", + "@reduxjs/toolkit": "^2.2.3", + "axios": "^1.6.8", "class-variance-authority": "^0.7.0", "clsx": "^2.1.0", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.0.2", "lucide-react": "^0.365.0", "react": "18.2.0", + "react-day-picker": "^8.10.1", "react-dom": "18.2.0", + "react-hook-form": "^7.51.3", + "react-redux": "^9.1.1", "react-router-dom": "6.11.2", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "zod": "^3.22.5" }, "devDependencies": { "@babel/core": "^7.14.5", diff --git a/packages/data/.babelrc b/packages/data/.babelrc new file mode 100644 index 0000000..1ea870e --- /dev/null +++ b/packages/data/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nx/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/packages/data/.eslintrc.json b/packages/data/.eslintrc.json new file mode 100644 index 0000000..a39ac5d --- /dev/null +++ b/packages/data/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/data/README.md b/packages/data/README.md new file mode 100644 index 0000000..0b472f6 --- /dev/null +++ b/packages/data/README.md @@ -0,0 +1,7 @@ +# data + +This library was generated with [Nx](https://nx.dev). + +## Running unit tests + +Run `nx test data` to execute the unit tests via [Vitest](https://vitest.dev/). diff --git a/packages/data/package.json b/packages/data/package.json new file mode 100644 index 0000000..fd931ad --- /dev/null +++ b/packages/data/package.json @@ -0,0 +1,12 @@ +{ + "name": "@mfe-tutorial/data", + "version": "0.0.1", + "main": "./index.js", + "types": "./index.d.ts", + "exports": { + ".": { + "import": "./index.mjs", + "require": "./index.js" + } + } +} diff --git a/packages/data/project.json b/packages/data/project.json new file mode 100644 index 0000000..148b05b --- /dev/null +++ b/packages/data/project.json @@ -0,0 +1,32 @@ +{ + "name": "data", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/data/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/data/**/*.{ts,tsx,js,jsx}"] + } + }, + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/packages/data" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + } + } +} diff --git a/packages/data/src/apis/base.api.ts b/packages/data/src/apis/base.api.ts new file mode 100644 index 0000000..5b25b38 --- /dev/null +++ b/packages/data/src/apis/base.api.ts @@ -0,0 +1,10 @@ +import axios from 'axios'; + +const api = axios; + +api.defaults.headers.post['Content-Type'] = 'application/json'; +api.defaults.headers.Accept = 'application/json'; +api.defaults.withCredentials = false; +api.defaults.timeout = 1000 * 60 * 2; // Two minutes + +export { api }; diff --git a/packages/data/src/apis/index.ts b/packages/data/src/apis/index.ts new file mode 100644 index 0000000..c6f14f2 --- /dev/null +++ b/packages/data/src/apis/index.ts @@ -0,0 +1,3 @@ +export { platziStoreApi } from './platzi.store.api'; + +export { api } from './base.api'; diff --git a/packages/data/src/apis/platzi.store.api.ts b/packages/data/src/apis/platzi.store.api.ts new file mode 100644 index 0000000..4c83efe --- /dev/null +++ b/packages/data/src/apis/platzi.store.api.ts @@ -0,0 +1,17 @@ +import { ENV } from '../common'; +import { + errorInterceptor, + requestInterceptor, + responseInterceptor, +} from '../lib/api.interceptors'; +import { api } from './base.api'; + +export const platziStoreApi = api.create({ + baseURL: ENV.NX_BASE_PLATZI_STORE_SERVICE_URL, +}); + +platziStoreApi.interceptors.request.use(requestInterceptor, (error) => + Promise.reject(error) +); + +platziStoreApi.interceptors.response.use(responseInterceptor, errorInterceptor); diff --git a/packages/data/src/common/environments.ts b/packages/data/src/common/environments.ts new file mode 100644 index 0000000..897b9a8 --- /dev/null +++ b/packages/data/src/common/environments.ts @@ -0,0 +1,41 @@ +import { z } from 'zod'; + +import { getEnvParams } from '../helpers/environment.helpers'; + +/** + * @description The environment schema for the container app. + */ +const envSchema = z.object({ + // INFO (serif) : NX_* Custom Environment variables + NX_BASE_PLATZI_STORE_SERVICE_URL: z.string(), + NX_ACCESS_TOKEN_KEY: z.string(), + NX_REFRESH_TOKEN_KEY: z.string(), + + // INFO (serif) : NX_* Base environment variables + NX_CLI_SET: z.string(), + NX_LOAD_DOT_ENV_FILES: z.string(), + NX_WORKSPACE_ROOT: z.string(), + NX_TERMINAL_OUTPUT_PATH: z.string(), + NX_STREAM_OUTPUT: z.string(), + NX_TASK_TARGET_PROJECT: z.string(), + NX_TASK_TARGET_TARGET: z.string(), + NX_TASK_TARGET_CONFIGURATION: z.string(), + NX_TASK_HASH: z.string(), +}); + +function initEnvironment() { + const [errors, env] = getEnvParams( + process.env as Record, + envSchema + ); + + if (errors) { + window.console.error(errors); + + throw new Error('Environment variables are not valid'); + } + + return env as z.infer; +} + +export { initEnvironment }; diff --git a/packages/data/src/common/index.ts b/packages/data/src/common/index.ts new file mode 100644 index 0000000..e1ca609 --- /dev/null +++ b/packages/data/src/common/index.ts @@ -0,0 +1,5 @@ +import { initEnvironment } from './environments'; + +export { default as paths } from './paths'; + +export const ENV = initEnvironment(); diff --git a/packages/data/src/common/paths.ts b/packages/data/src/common/paths.ts new file mode 100644 index 0000000..984db32 --- /dev/null +++ b/packages/data/src/common/paths.ts @@ -0,0 +1,5 @@ +export default { + home: '/', + login: '/login', + info: '/info', +}; diff --git a/packages/data/src/features/counter/counterSlice.ts b/packages/data/src/features/counter/counterSlice.ts new file mode 100644 index 0000000..7a28f42 --- /dev/null +++ b/packages/data/src/features/counter/counterSlice.ts @@ -0,0 +1,40 @@ +/* eslint-disable import/no-cycle */ +import type { PayloadAction } from '@reduxjs/toolkit'; +import { createSlice } from '@reduxjs/toolkit'; + +import { RootState } from '../../store'; + +// Define a type for the slice state +interface CounterState { + value: number; +} + +// Define the initial state using that type +const initialState: CounterState = { + value: 0, +}; + +export const counterSlice = createSlice({ + name: 'counter', + // `createSlice` will infer the state type from the `initialState` argument + initialState, + reducers: { + increment: (state) => { + state.value += 1; + }, + decrement: (state) => { + state.value -= 1; + }, + // Use the PayloadAction type to declare the contents of `action.payload` + incrementByAmount: (state, action: PayloadAction) => { + state.value += action.payload; + }, + }, +}); + +export const { increment, decrement, incrementByAmount } = counterSlice.actions; + +// Other code such as selectors can use the imported `RootState` type +export const selectCount = (state: RootState) => state.counter.value; + +export default counterSlice.reducer; diff --git a/packages/data/src/helpers/environment.helpers.ts b/packages/data/src/helpers/environment.helpers.ts new file mode 100644 index 0000000..e2417da --- /dev/null +++ b/packages/data/src/helpers/environment.helpers.ts @@ -0,0 +1,48 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { z } from 'zod'; + +/** + * @description Gets the parameters from the environment variables. + * @param {Record} env The environment variables. + * @param {z.ZodObject} schema The schema. + * @returns The errors and the data. + */ +export function getEnvParams( + env: Record, + schema: z.ZodObject +): [Record | null, z.infer | null] { + const data: Record = {}; + const errors: Record = {}; + + for (const key in schema.shape) { + if (Object.prototype.hasOwnProperty.call(schema.shape, key)) { + const value = env[key]; + + if (value === undefined) { + errors[key] = `ERROR (serif) : Missing required env var: ${key}`; + } else { + try { + data[key] = (schema.shape[key] as z.ZodTypeAny)?.parse(value); + } catch (error) { + let message = 'INFO (serif) : Invalid env var'; + + if (error instanceof z.ZodError) { + message = `ERROR (serif) : ${error.errors[0].message}`; + } else if (error instanceof Error) { + message = `ERROR (serif) : ${error.message}`; + } + + errors[key] = message; + } + } + } + } + + if (Object.keys(errors).length) { + return [errors, null]; + } + + return [null, data as z.infer]; +} diff --git a/packages/data/src/helpers/index.ts b/packages/data/src/helpers/index.ts new file mode 100644 index 0000000..4907dba --- /dev/null +++ b/packages/data/src/helpers/index.ts @@ -0,0 +1 @@ +export { handleErrorResponse } from './service.helpers'; diff --git a/packages/data/src/helpers/service.helpers.ts b/packages/data/src/helpers/service.helpers.ts new file mode 100644 index 0000000..ccee7a3 --- /dev/null +++ b/packages/data/src/helpers/service.helpers.ts @@ -0,0 +1,77 @@ +import { AxiosError } from 'axios'; +import { ZodError } from 'zod'; + +import { BaseServiceResponse } from '../types'; + +/** + * @description Handles the error response. + * @param {unknown} error - Error + * @param {string | undefined} message - Message + * @returns {BaseServiceResponse} The service response. + * @example + * const error = new Error('An error occurred.'); + * const result = handleErrorResponse(error); + * console.log(result); // { data: null, message: 'An error occurred.', success: false } + * @example + * const error = new AxiosError('An error occurred.'); + * const result = handleErrorResponse(error); + * console.log(result); // { data: null, message: 'An error occurred.', success: false } + */ +export const handleErrorResponse = ( + error: unknown, + message: string | undefined = 'Unknown error occurred.' +): BaseServiceResponse => { + let status: number | undefined; + + if (error instanceof Error) { + message = error.message; + status = 500; + } + + if (error instanceof AxiosError) { + message = error.message; + status = error.response?.status; + } + + if (error instanceof ZodError) { + const paths = error.errors.map((err) => err.path[1]); + const uniquePaths = [...new Set(paths)]; + + message = `Error in fields: ${uniquePaths.join(', ')}`; + + status = 400; + } + + return { + data: null, + message, + success: false, + status, + }; +}; + +/** + * @description Formats the message of a service response. + * @param {string} message The message to be formatted. + * @param {string[]} replacerValues The strings to replace the placeholders in message. + * @returns {string} The formatted message. + * @example + * const message = 'The {0} is {1}!'; + * const replace = ['answer', '42']; + * const result = getServiceResponseMessage(message, replace); + * console.log(result); // The answer is 42! + */ +export const getServiceResponseMessage = ( + message: string, + replacerValues?: string[] +): string => { + let result = message; + + if (replacerValues) { + replacerValues.forEach((item, index) => { + result = result.replace(`{${index}}`, item); + }); + } + + return result; +}; diff --git a/packages/data/src/hooks/base-query/index.ts b/packages/data/src/hooks/base-query/index.ts new file mode 100644 index 0000000..35764a0 --- /dev/null +++ b/packages/data/src/hooks/base-query/index.ts @@ -0,0 +1,44 @@ +import type { BaseQueryFn } from '@reduxjs/toolkit/query'; +import type { AxiosRequestConfig } from 'axios'; +import axios from 'axios'; + +import { handleErrorResponse } from '../../helpers/service.helpers'; + +const axiosBaseQuery = + ( + { baseUrl }: { baseUrl: string } = { baseUrl: '' } + ): BaseQueryFn< + { + url: string; + method?: AxiosRequestConfig['method']; + data?: AxiosRequestConfig['data']; + params?: AxiosRequestConfig['params']; + headers?: AxiosRequestConfig['headers']; + }, + unknown, + unknown + > => + async ({ url, method, data, params, headers }) => { + try { + const result = await axios({ + url: baseUrl + url, + method, + data, + params: params as unknown as Record, + headers, + }); + + return { data: result.data }; + } catch (error) { + const result = handleErrorResponse(error); + + return { + error: { + status: result.status, + data: result.message, + }, + }; + } + }; + +export { axiosBaseQuery }; diff --git a/packages/data/src/hooks/index.ts b/packages/data/src/hooks/index.ts new file mode 100644 index 0000000..300622d --- /dev/null +++ b/packages/data/src/hooks/index.ts @@ -0,0 +1,4 @@ +export * from './use-random'; +export * from './use-counter'; +export * from './use-platzi-store-auth'; +export * from './use-platzi-store-products'; diff --git a/packages/data/src/hooks/use-counter/index.ts b/packages/data/src/hooks/use-counter/index.ts new file mode 100644 index 0000000..e8b4886 --- /dev/null +++ b/packages/data/src/hooks/use-counter/index.ts @@ -0,0 +1,24 @@ +import { + decrement, + increment, + incrementByAmount, + selectCount, +} from '../../features/counter/counterSlice'; +import { useAppDispatch, useAppSelector } from '../../store'; + +export function useCounter() { + const counterValue = useAppSelector(selectCount); + const dispatch = useAppDispatch(); + const handleIncrement = () => dispatch(increment()); + const handleDecrement = () => dispatch(decrement()); + + const handleIncrementByAmount = (amount: number) => + dispatch(incrementByAmount(amount)); + + return { + counterValue, + handleIncrement, + handleDecrement, + handleIncrementByAmount, + }; +} diff --git a/packages/data/src/hooks/use-platzi-store-auth/index.tsx b/packages/data/src/hooks/use-platzi-store-auth/index.tsx new file mode 100644 index 0000000..bea19af --- /dev/null +++ b/packages/data/src/hooks/use-platzi-store-auth/index.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; + +import { ENV } from '../../common'; +import { login, LoginRequest, refreshToken } from '../../services'; + +export function usePlatziStoreAuth() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleLogin = async (info: LoginRequest) => { + setLoading(true); + + const response = await login(info); + + const result = { + success: false, + message: 'Please check your email and password and try again.', + title: 'Login Failed', + }; + + if (response.success && response.data) { + localStorage.setItem(ENV.NX_ACCESS_TOKEN_KEY, response.data.access_token); + localStorage.setItem( + ENV.NX_REFRESH_TOKEN_KEY, + response.data.refresh_token + ); + + result.success = true; + result.message = 'You have successfully logged in!'; + result.title = 'Login Success'; + } else { + setError('Please check your email and password and try again.'); + } + + setLoading(false); + + return result; + }; + + const handleRefreshToken = async () => { + const token = localStorage.getItem(ENV.NX_REFRESH_TOKEN_KEY); + + if (token) { + const response = await refreshToken({ refreshToken: token }); + + if (response.success && response.data) { + localStorage.setItem( + ENV.NX_ACCESS_TOKEN_KEY, + response.data.access_token + ); + localStorage.setItem( + ENV.NX_REFRESH_TOKEN_KEY, + response.data.refresh_token + ); + } else { + setError(response.message); + } + } + }; + + const onResetError = () => setError(null); + + return { + loading, + error, + handleRefreshToken, + handleLogin, + onResetError, + }; +} diff --git a/packages/data/src/hooks/use-platzi-store-products/index.ts b/packages/data/src/hooks/use-platzi-store-products/index.ts new file mode 100644 index 0000000..f15812b --- /dev/null +++ b/packages/data/src/hooks/use-platzi-store-products/index.ts @@ -0,0 +1,163 @@ +import { useEffect, useState } from 'react'; + +import { + createProduct, + CreateProductRequest, + deleteProduct, + getProduct, + getProducts, + Product, + updateProduct, + UpdateProductRequest, +} from '../../services'; + +export type ProductError = { + message: string; + title: string; +}; + +export type Data = + | { status: 'idle' } + | { status: 'loading' } + | { status: 'error'; error: ProductError } + | { status: 'hasData'; data: Product[]; message?: string } + | { status: 'hasSingleData'; data: Product }; + +export default function usePlatziStoreProducts(fetchOnMount = true) { + const [data, setData] = useState({ status: 'idle' }); + + const fetchProducts = async (message?: string) => { + if (data.status !== 'loading') { + setData({ status: 'loading' }); + } + + const response = await getProducts(); + + if (response.success && response.data) { + setData({ status: 'hasData', data: response.data, message }); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Products Fetch Failed', + }, + }); + } + }; + + const fetchProduct = async (id: string) => { + setData({ status: 'loading' }); + + const response = await getProduct(id); + + if (response.success && response.data) { + setData({ status: 'hasSingleData', data: response.data }); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Fetch Failed', + }, + }); + } + }; + + const create = async ( + product: CreateProductRequest, + canGetProducts = true + ) => { + setData({ status: 'loading' }); + const response = await createProduct(product); + + if (response.success && response.data && canGetProducts) { + await fetchProducts('Product created successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Creation Failed', + }, + }); + } + + if (response.success && data.status === 'loading') { + setData({ status: 'idle' }); + } + }; + + const update = async ( + id: string, + product: UpdateProductRequest, + canGetProducts = true + ) => { + setData({ status: 'loading' }); + const response = await updateProduct(id, product); + + if ((response.success && response.data, canGetProducts)) { + await fetchProducts('Product updated successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Update Failed', + }, + }); + } + }; + + const remove = async (id: string) => { + setData({ status: 'loading' }); + const response = await deleteProduct(id); + + if (response.success && response.data) { + await fetchProducts('Product deleted successfully! 🎉'); + } else { + setData({ + status: 'error', + error: { + message: response.message, + title: 'Product Deletion Failed', + }, + }); + } + + if (response.success && data.status === 'loading') { + setData({ status: 'idle' }); + } + }; + + const hasDataMessage = data.status === 'hasData' ? !!data.message : false; + + useEffect(() => { + if (hasDataMessage) { + const timeout = setTimeout(() => { + setData((prev) => ({ + ...prev, + message: undefined, + })); + }, 3000); + + return () => clearTimeout(timeout); + } + }, [hasDataMessage]); + + useEffect(() => { + if (fetchOnMount) { + fetchProducts(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchOnMount]); + + return { + fetchProducts, + fetchProduct, + create, + update, + remove, + data, + }; +} diff --git a/packages/data/src/hooks/use-random/index.ts b/packages/data/src/hooks/use-random/index.ts new file mode 100644 index 0000000..2416436 --- /dev/null +++ b/packages/data/src/hooks/use-random/index.ts @@ -0,0 +1,9 @@ +export type UseRandomProps = { + multiplier: number; +}; + +export function useRandom({ multiplier }: UseRandomProps) { + return { + randomizedValue: Math.random() * multiplier, + }; +} diff --git a/packages/data/src/index.ts b/packages/data/src/index.ts new file mode 100644 index 0000000..bfe896d --- /dev/null +++ b/packages/data/src/index.ts @@ -0,0 +1,8 @@ +export * from './hooks'; +export * from './types'; +export * from './apis'; +export * from './helpers'; +export * from './common'; +export * from './services'; +export * from './store'; +export { default as DataLayerProviders } from './providers'; diff --git a/packages/data/src/lib/api.interceptors.ts b/packages/data/src/lib/api.interceptors.ts new file mode 100644 index 0000000..d303c62 --- /dev/null +++ b/packages/data/src/lib/api.interceptors.ts @@ -0,0 +1,22 @@ +import { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'; + +import { ENV } from '../common'; +import { handleErrorResponse } from '../helpers'; + +// TODO (serif) : handle request here +export const requestInterceptor = (config: InternalAxiosRequestConfig) => { + const token = localStorage.getItem(ENV.NX_ACCESS_TOKEN_KEY); + + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + + return config; +}; + +// TODO (serif) : handle response here +export const responseInterceptor = (response: AxiosResponse) => response; + +// TODO (serif) : handle error response here +export const errorInterceptor = async (error: AxiosError) => + Promise.reject(handleErrorResponse(error)); diff --git a/packages/data/src/providers/index.tsx b/packages/data/src/providers/index.tsx new file mode 100644 index 0000000..4e039f2 --- /dev/null +++ b/packages/data/src/providers/index.tsx @@ -0,0 +1,8 @@ +import React, { PropsWithChildren } from 'react'; +import { Provider } from 'react-redux'; + +import { store } from '../store'; + +export default function DataLayerProviders({ children }: PropsWithChildren) { + return {children}; +} diff --git a/packages/data/src/services/index.ts b/packages/data/src/services/index.ts new file mode 100644 index 0000000..b3e8627 --- /dev/null +++ b/packages/data/src/services/index.ts @@ -0,0 +1 @@ +export * from './platzi'; diff --git a/packages/data/src/services/platzi/auth/index.ts b/packages/data/src/services/platzi/auth/index.ts new file mode 100644 index 0000000..f478ff7 --- /dev/null +++ b/packages/data/src/services/platzi/auth/index.ts @@ -0,0 +1,114 @@ +import { handleErrorResponse } from '../../../helpers'; +import { BaseServiceResponse } from '../../../types'; +import { PLATZI_STORE_PRODUCTS_PATHS } from '../contants'; +import { platziStoreApiMethods as methods } from '../methods'; +import { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +} from './schemas'; +import { + LoginRequest, + LoginResponse, + RefreshTokenRequest, + RefreshTokenResponse, + UserProfileResponse, +} from './types'; + +/** + * @description Logs a user in. + * @param {LoginRequest} info The user to log in. + * @returns {Promise>} A Promise that resolves to a LoginResponse. + */ +export const login = async ( + info: LoginRequest +): Promise> => { + try { + const infos = loginRequestSchema.parse(info); + + const response = await methods.post( + PLATZI_STORE_PRODUCTS_PATHS.AUTH.LOGIN, + infos + ); + + const data = loginResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Gets the user profile. + * @returns {Promise>} A Promise that resolves to a UserProfileResponse. + */ +export const getUserProfile = async (): Promise< + BaseServiceResponse +> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.AUTH.PROFILE + ); + + const data = userProfileResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Refreshes the token. + * @param {RefreshTokenRequest} refreshToken The refresh token. + * @returns {Promise>} A Promise that resolves to a RefreshTokenResponse. + */ +export const refreshToken = async ( + token: RefreshTokenRequest +): Promise> => { + try { + const values = refreshTokenRequestSchema.parse(token); + + const response = await methods.post< + RefreshTokenRequest, + RefreshTokenResponse + >(PLATZI_STORE_PRODUCTS_PATHS.AUTH.REFRESH_TOKEN, values); + + const data = refreshTokenResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +export type { + LoginRequest, + LoginResponse, + UserProfileResponse, + RefreshTokenRequest, + RefreshTokenResponse, +}; + +export { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +}; diff --git a/packages/data/src/services/platzi/auth/schemas.ts b/packages/data/src/services/platzi/auth/schemas.ts new file mode 100644 index 0000000..b1d5a62 --- /dev/null +++ b/packages/data/src/services/platzi/auth/schemas.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const loginRequestSchema = z.object({ + email: z.string().email('Please enter a valid email'), + password: z.string().min(6, 'Password must be at least 6 characters'), +}); + +export const loginResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), +}); + +export const userProfileResponseSchema = z.object({ + id: z.number(), + email: z.string(), + password: z.string(), + name: z.string(), + role: z.string(), + avatar: z.string(), +}); + +export const refreshTokenRequestSchema = z.object({ refreshToken: z.string() }); + +export const refreshTokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), +}); diff --git a/packages/data/src/services/platzi/auth/types.ts b/packages/data/src/services/platzi/auth/types.ts new file mode 100644 index 0000000..29dc603 --- /dev/null +++ b/packages/data/src/services/platzi/auth/types.ts @@ -0,0 +1,19 @@ +import { z } from 'zod'; + +import { + loginRequestSchema, + loginResponseSchema, + refreshTokenRequestSchema, + refreshTokenResponseSchema, + userProfileResponseSchema, +} from './schemas'; + +export type LoginRequest = z.infer; + +export type LoginResponse = z.infer; + +export type UserProfileResponse = z.infer; + +export type RefreshTokenRequest = z.infer; + +export type RefreshTokenResponse = z.infer; diff --git a/packages/data/src/services/platzi/contants.ts b/packages/data/src/services/platzi/contants.ts new file mode 100644 index 0000000..b2f4774 --- /dev/null +++ b/packages/data/src/services/platzi/contants.ts @@ -0,0 +1,17 @@ +/** + * @description PRODUCTS paths for the PLATZI STORE API service + */ +export const PLATZI_STORE_PRODUCTS_PATHS = { + PRODUCT: { + GET_ALL: '/products', + GET_SINGLE: '/products/:id', + CREATE: '/products', + UPDATE: '/products/:id', + DELETE: '/products/:id', + }, + AUTH: { + LOGIN: '/auth/login', + PROFILE: '/auth/profile', + REFRESH_TOKEN: '/auth/refresh-token', + }, +}; diff --git a/packages/data/src/services/platzi/index.ts b/packages/data/src/services/platzi/index.ts new file mode 100644 index 0000000..205ef67 --- /dev/null +++ b/packages/data/src/services/platzi/index.ts @@ -0,0 +1,4 @@ +export * from './products'; +export * from './auth'; +export * from './methods'; +export * from './contants'; diff --git a/packages/data/src/services/platzi/methods.ts b/packages/data/src/services/platzi/methods.ts new file mode 100644 index 0000000..cc4b919 --- /dev/null +++ b/packages/data/src/services/platzi/methods.ts @@ -0,0 +1,94 @@ +import { AxiosRequestConfig, AxiosResponse } from 'axios'; + +import { platziStoreApi } from '../../apis'; + +/** + * @description Sends a GET request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +async function get( + url: string, + config?: AxiosRequestConfig +): Promise> { + const response = await platziStoreApi.get(url, config); + + return response; +} + +/** + * @description Sends a POST request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const post = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.post(url, data, config); + + return response; +}; + +/** + * @description Sends a PUT request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const put = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.put(url, data, config); + + return response; +}; + +/** + * @description Sends a PATCH request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {TRequest} data The data to be sent as the request body. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const patch = async ( + url: string, + data: TRequest, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.patch(url, data, config); + + return response; +}; + +/** + * @description Sends a DELETE request to the specified URL of postApi. + * @param {string} url The URL to send the request to. + * @param {AxiosRequestConfig} config The config specific for this request (merged with this.defaults). + * @returns {Promise>} A Promise that resolves to a AxiosResponse. + */ +export const remove = async ( + url: string, + config?: AxiosRequestConfig +): Promise> => { + const response = await platziStoreApi.delete(url, config); + + return response; +}; + +const platziStoreApiMethods = { + get, + post, + put, + patch, + remove, +}; + +export { platziStoreApiMethods }; diff --git a/packages/data/src/services/platzi/products/index.ts b/packages/data/src/services/platzi/products/index.ts new file mode 100644 index 0000000..f423eda --- /dev/null +++ b/packages/data/src/services/platzi/products/index.ts @@ -0,0 +1,172 @@ +import { handleErrorResponse } from '../../../helpers'; +import { BaseServiceResponse } from '../../../types'; +import { PLATZI_STORE_PRODUCTS_PATHS } from '../contants'; +import { platziStoreApiMethods as methods } from '../methods'; +import { + allProductsResponseSchema, + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +} from './schemas'; +import { + CreateProductRequest, + CreateProductResponse, + Product, + UpdateProductRequest, + UpdateProductResponse, +} from './types'; + +/** + * @description Gets all products from the API. + * @returns {Promise>} A Promise that resolves to an array of Post. + */ +export const getProducts = async (): Promise< + BaseServiceResponse +> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.GET_ALL, + { + params: { + limit: 10, + offset: 1, + }, + } + ); + + const data = allProductsResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Gets a single product from the API. + * @param {string} id The product ID. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const getProduct = async ( + id: string +): Promise> => { + try { + const response = await methods.get( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.GET_SINGLE.replace(':id', id) + ); + + const data = productSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Creates a new product. + * @param {CreateProductRequest} product The product to create. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const createProduct = async ( + product: CreateProductRequest +): Promise> => { + try { + const values = createProductRequestSchema.parse(product); + + const response = await methods.post< + CreateProductRequest, + CreateProductResponse + >(PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.CREATE, values); + + const data = createProductResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Updates a product. + * @param {string} id The product ID. + * @param {UpdateProductRequest} product The product to update. + * @returns {Promise>} A Promise that resolves to a Product. + */ +export const updateProduct = async ( + id: string, + product: UpdateProductRequest +): Promise> => { + try { + const values = updateProductRequestSchema.parse(product); + + const response = await methods.put< + UpdateProductRequest, + UpdateProductResponse + >(PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.UPDATE.replace(':id', id), values); + + const data = updateProductResponseSchema.parse(response.data); + + return { + data, + message: response.statusText, + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +/** + * @description Deletes a product. + * @param {string} id The product ID. + * @returns {Promise>} A Promise that resolves to null. + */ +export const deleteProduct = async ( + id: string +): Promise> => { + try { + const res = await methods.remove( + PLATZI_STORE_PRODUCTS_PATHS.PRODUCT.DELETE.replace(':id', id) + ); + + return { + data: res.data, + message: 'Product deleted successfully.', + success: true, + }; + } catch (e) { + return handleErrorResponse(e); + } +}; + +export type { + CreateProductRequest, + CreateProductResponse, + Product, + UpdateProductRequest, + UpdateProductResponse, +}; + +export { + allProductsResponseSchema, + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +}; diff --git a/packages/data/src/services/platzi/products/schemas.ts b/packages/data/src/services/platzi/products/schemas.ts new file mode 100644 index 0000000..5f96556 --- /dev/null +++ b/packages/data/src/services/platzi/products/schemas.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +export const productSchema = z.object({ + id: z.number(), + title: z.string(), + price: z.number(), + description: z.string(), + category: z.object({ id: z.number(), name: z.string(), image: z.string() }), + images: z.array(z.string()), +}); + +export const allProductsResponseSchema = z.array(productSchema); + +export const createProductRequestSchema = z.object({ + title: z.string(), + price: z.number(), + description: z.string(), + categoryId: z.number(), + images: z.array(z.string()), +}); + +export const createProductResponseSchema = z.object({ + title: z.string(), + price: z.number(), + description: z.string(), + images: z.array(z.string()), + category: z.object({ + id: z.number(), + name: z.string(), + image: z.string(), + creationAt: z.string(), + updatedAt: z.string(), + }), + id: z.number(), + creationAt: z.string(), + updatedAt: z.string(), +}); + +export const updateProductRequestSchema = z.object({ + title: z.string(), + price: z.number(), +}); + +export const updateProductResponseSchema = z.object({ + id: z.number(), + title: z.string(), + price: z.number(), + description: z.string(), + images: z.array(z.string()), + creationAt: z.string(), + updatedAt: z.string(), + category: z.object({ + id: z.number(), + name: z.string(), + image: z.string(), + creationAt: z.string(), + updatedAt: z.string(), + }), +}); diff --git a/packages/data/src/services/platzi/products/types.ts b/packages/data/src/services/platzi/products/types.ts new file mode 100644 index 0000000..3b1bc30 --- /dev/null +++ b/packages/data/src/services/platzi/products/types.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +import { + createProductRequestSchema, + createProductResponseSchema, + productSchema, + updateProductRequestSchema, + updateProductResponseSchema, +} from './schemas'; + +export type Product = z.infer; + +export type CreateProductRequest = z.infer; + +export type CreateProductResponse = z.infer; + +export type UpdateProductRequest = z.infer; + +export type UpdateProductResponse = z.infer; + +export type DeleteProductResponse = boolean; diff --git a/packages/data/src/store/index.ts b/packages/data/src/store/index.ts new file mode 100644 index 0000000..8e9c29d --- /dev/null +++ b/packages/data/src/store/index.ts @@ -0,0 +1,19 @@ +/* eslint-disable import/no-cycle */ +import { configureStore } from '@reduxjs/toolkit'; +import { useDispatch, useSelector } from 'react-redux'; + +import counterSlice from '../features/counter/counterSlice'; + +export const store = configureStore({ + reducer: { + counter: counterSlice, + }, +}); + +export type RootState = ReturnType; +// Inferred type: {posts: PostsState, comments: CommentsState, users: UsersState} +export type AppDispatch = typeof store.dispatch; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/packages/data/src/types/index.ts b/packages/data/src/types/index.ts new file mode 100644 index 0000000..a04a94b --- /dev/null +++ b/packages/data/src/types/index.ts @@ -0,0 +1,15 @@ +/** + * @description Base service response interface + * @template T - Generic type for data + * @interface BaseServiceResponse + * @property {T | null} data - Data + * @property {string} message - Message + * @property {boolean} success - Success + * @property {number | undefined} status - Status + */ +export type BaseServiceResponse = { + data: T | null; + message: string; + success: boolean; + status?: number; +}; diff --git a/packages/data/tsconfig.json b/packages/data/tsconfig.json new file mode 100644 index 0000000..6734c59 --- /dev/null +++ b/packages/data/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "types": ["vite/client"] + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + } + ], + "extends": "../../tsconfig.base.json" +} diff --git a/packages/data/tsconfig.lib.json b/packages/data/tsconfig.lib.json new file mode 100644 index 0000000..41c1f2f --- /dev/null +++ b/packages/data/tsconfig.lib.json @@ -0,0 +1,30 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "node", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts", + "vite/client" + ] + }, + "exclude": [ + "**/*.spec.ts", + "**/*.test.ts", + "**/*.spec.tsx", + "**/*.test.tsx", + "**/*.spec.js", + "**/*.test.js", + "**/*.spec.jsx", + "**/*.test.jsx" + ], + "include": [ + "src/**/*.js", + "src/**/*.jsx", + "src/**/*.ts", + "src/**/*.tsx", + "src/helpers/index.ts", + "src/lib/api.interceptors.ts" + ] +} diff --git a/packages/data/vite.config.ts b/packages/data/vite.config.ts new file mode 100644 index 0000000..819a3b2 --- /dev/null +++ b/packages/data/vite.config.ts @@ -0,0 +1,44 @@ +// eslint-disable-next-line spaced-comment +/// +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; +import react from '@vitejs/plugin-react'; +import * as path from 'path'; +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; + +export default defineConfig({ + cacheDir: '../../node_modules/.vite/data', + + plugins: [ + react(), + nxViteTsPaths(), + dts({ + entryRoot: 'src', + tsConfigFilePath: path.join(__dirname, 'tsconfig.lib.json'), + skipDiagnostics: true, + }), + ], + + // Uncomment this if you are using workers. + // worker: { + // plugins: [ nxViteTsPaths() ], + // }, + + // Configuration for building your library. + // See: https://vitejs.dev/guide/build.html#library-mode + build: { + lib: { + // Could also be a dictionary or array of multiple entry points. + entry: 'src/index.ts', + name: 'data', + fileName: 'index', + // Change this to the formats you want to support. + // Don't forget to update your package.json as well. + formats: ['es', 'cjs'], + }, + rollupOptions: { + // External packages that should not be bundled into your library. + external: ['react', 'react-dom', 'react/jsx-runtime'], + }, + }, +}); diff --git a/packages/ui/src/components/form-fields/date-field.tsx b/packages/ui/src/components/form-fields/date-field.tsx new file mode 100644 index 0000000..7575429 --- /dev/null +++ b/packages/ui/src/components/form-fields/date-field.tsx @@ -0,0 +1,75 @@ +import { format } from 'date-fns'; +import { CalendarIcon } from 'lucide-react'; +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { cn } from '../../lib'; +import { Button } from '../ui'; +import { Calendar } from '../ui/calendar'; +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; + +type DateFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; +}; + +export function DateField({ + control, + label, + name, + description, +}: DateFieldProps) { + return ( + ( + + {label} + + + + + + + + + date > new Date() || date < new Date('1900-01-01') + } + mode="single" + onSelect={field.onChange} + selected={field.value} + /> + + + {description} + + + )} + /> + ); +} diff --git a/packages/ui/src/components/form-fields/index.ts b/packages/ui/src/components/form-fields/index.ts new file mode 100644 index 0000000..93cfde8 --- /dev/null +++ b/packages/ui/src/components/form-fields/index.ts @@ -0,0 +1,4 @@ +export * from './input-field'; +export * from './text-area-field'; +export * from './select-field'; +export * from './date-field'; diff --git a/packages/ui/src/components/form-fields/input-field.tsx b/packages/ui/src/components/form-fields/input-field.tsx new file mode 100644 index 0000000..229007b --- /dev/null +++ b/packages/ui/src/components/form-fields/input-field.tsx @@ -0,0 +1,43 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { Input, InputProps } from '../ui/input'; + +type InputFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; +} & InputProps; + +export function InputField({ + control, + label, + name, + description, + ...props +}: InputFieldProps) { + return ( + ( + + {label} + + + + {description} + + + )} + /> + ); +} diff --git a/packages/ui/src/components/form-fields/select-field.tsx b/packages/ui/src/components/form-fields/select-field.tsx new file mode 100644 index 0000000..6e7c25c --- /dev/null +++ b/packages/ui/src/components/form-fields/select-field.tsx @@ -0,0 +1,61 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../ui/select'; + +type SelectFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; + options: { label: string; value: string }[]; +}; + +export function SelectField({ + control, + label, + name, + description, + options, +}: SelectFieldProps) { + return ( + ( + + {label} + + {description} + + + )} + /> + ); +} diff --git a/packages/ui/src/components/form-fields/text-area-field.tsx b/packages/ui/src/components/form-fields/text-area-field.tsx new file mode 100644 index 0000000..7a0ba48 --- /dev/null +++ b/packages/ui/src/components/form-fields/text-area-field.tsx @@ -0,0 +1,43 @@ +import { Control, FieldValues, Path } from 'react-hook-form'; + +import { + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '../ui/form'; +import { Textarea, type TextareaProps } from '../ui/textarea'; + +type TextAreaFieldProps = { + control: Control; + label?: string; + name: Path; + description?: string; +} & TextareaProps; + +export function TextAreaField({ + control, + label, + name, + description, + ...props +}: TextAreaFieldProps) { + return ( + ( + + {label} + +