├── .nvmrc ├── src ├── declaration.d.ts ├── ui │ ├── styles │ │ ├── utilities │ │ │ ├── index.scss │ │ │ └── margins.scss │ │ ├── objects │ │ │ └── Flex │ │ │ │ ├── index.ts │ │ │ │ ├── Flex.module.scss │ │ │ │ └── Flex.tsx │ │ ├── generic │ │ │ ├── index.scss │ │ │ ├── reset.scss │ │ │ └── normalize.scss │ │ ├── tools │ │ │ ├── index.scss │ │ │ ├── functions.scss │ │ │ ├── mixins │ │ │ │ ├── gridLayout.scss │ │ │ │ ├── containers.scss │ │ │ │ └── fonts.scss │ │ │ └── mediaQueries.scss │ │ ├── itcss.scss │ │ └── settings │ │ │ ├── fonts │ │ │ ├── index.ts │ │ │ └── fonts.ts │ │ │ ├── index.scss │ │ │ ├── spacings.ts │ │ │ ├── icons.ts │ │ │ ├── z-indexes.scss │ │ │ ├── breakpoints.ts │ │ │ ├── index.ts │ │ │ ├── shadows.scss │ │ │ ├── colors.ts │ │ │ ├── spacings.scss │ │ │ ├── icons.scss │ │ │ ├── grid.scss │ │ │ └── colors.scss │ ├── components │ │ └── atoms │ │ │ ├── Image │ │ │ ├── index.ts │ │ │ └── Image.tsx │ │ │ └── Text │ │ │ ├── index.ts │ │ │ ├── Text.tsx │ │ │ └── Text.module.scss │ ├── views │ │ └── PopularMovies │ │ │ ├── index.ts │ │ │ ├── PopularMovies.module.scss │ │ │ ├── __tests__ │ │ │ └── PopularMovies.test.tsx │ │ │ └── PopularMovies.tsx │ ├── __tests__ │ │ └── render.tsx │ └── utils │ │ └── classNames.ts ├── core │ ├── Shared │ │ ├── infrastructure │ │ │ ├── wrappers │ │ │ │ ├── isEmtpy.ts │ │ │ │ ├── isUndefined.ts │ │ │ │ └── isDefined.ts │ │ │ ├── envManager.ts │ │ │ ├── TheMovieDBApiClient.ts │ │ │ ├── ApiClient.ts │ │ │ └── __tests__ │ │ │ │ └── ApiClient.test.ts │ │ └── _di │ │ │ └── index.ts │ └── Movie │ │ ├── domain │ │ ├── Movies.repository.ts │ │ ├── Movie.ts │ │ └── __mocks__ │ │ │ └── MovieBuilder.ts │ │ ├── infrastructure │ │ ├── PopularMoviesDTO.ts │ │ ├── ImageSize.ts │ │ ├── Movies.api.repository.ts │ │ ├── __mocks__ │ │ │ └── PopularMoviesDTOBuilder.ts │ │ └── __tests__ │ │ │ └── Movies.api.repository.test.ts │ │ ├── _di │ │ └── index.ts │ │ └── usecase │ │ ├── GetPopularMoviesAndItsHighlight.ts │ │ └── __tests__ │ │ └── GetPopularMoviesAndItsHighlight.test.ts ├── _di │ ├── resolvers.ts │ ├── types.ts │ ├── modules.ts │ ├── container.ts │ └── mocks │ │ └── registerMockModule.ts └── app │ ├── page.tsx │ ├── layout.module.scss │ └── layout.tsx ├── .npmrc ├── .husky ├── commit-msg ├── pre-push ├── pre-commit └── common.sh ├── .env.dist ├── .prettierrc.json ├── next.config.js ├── .eslintrc.json ├── README.md ├── .lintstagedrc.js ├── knip.json ├── jest.setup.integration.ts ├── .gitignore ├── .eslintignore ├── .prettierignore ├── jest.config.unit.js ├── jest.config.integration.js ├── tsconfig.json ├── commitlint.config.js ├── scripts └── pre-push.js ├── package.json └── .stylelintrc.json /.nvmrc: -------------------------------------------------------------------------------- 1 | lts/hydrogen -------------------------------------------------------------------------------- /src/declaration.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /src/ui/styles/utilities/index.scss: -------------------------------------------------------------------------------- 1 | @import 'margins'; 2 | -------------------------------------------------------------------------------- /src/ui/styles/objects/Flex/index.ts: -------------------------------------------------------------------------------- 1 | export { Flex } from './Flex' 2 | -------------------------------------------------------------------------------- /src/ui/components/atoms/Image/index.ts: -------------------------------------------------------------------------------- 1 | export { Image } from './Image' 2 | -------------------------------------------------------------------------------- /src/ui/styles/generic/index.scss: -------------------------------------------------------------------------------- 1 | @import 'reset'; 2 | @import 'normalize'; 3 | -------------------------------------------------------------------------------- /src/ui/views/PopularMovies/index.ts: -------------------------------------------------------------------------------- 1 | export { PopularMovies } from './PopularMovies' 2 | -------------------------------------------------------------------------------- /src/ui/styles/tools/index.scss: -------------------------------------------------------------------------------- 1 | @import 'functions'; 2 | @import 'mediaQueries'; 3 | @import 'mixins/fonts'; 4 | -------------------------------------------------------------------------------- /src/ui/components/atoms/Text/index.ts: -------------------------------------------------------------------------------- 1 | export { Text } from './Text' 2 | export type { TextColor } from './Text' 3 | -------------------------------------------------------------------------------- /src/ui/styles/itcss.scss: -------------------------------------------------------------------------------- 1 | @import 'settings'; 2 | @import 'generic'; 3 | @import 'tools'; 4 | @import 'utilities'; 5 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit "${1}" 5 | -------------------------------------------------------------------------------- /src/core/Shared/infrastructure/wrappers/isEmtpy.ts: -------------------------------------------------------------------------------- 1 | import isEmpty from 'lodash/isEmpty' 2 | 3 | export { isEmpty } 4 | -------------------------------------------------------------------------------- /src/_di/resolvers.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-restricted-imports 2 | export { asFunction, asValue, Lifetime } from 'awilix' 3 | -------------------------------------------------------------------------------- /src/ui/styles/settings/fonts/index.ts: -------------------------------------------------------------------------------- 1 | export type { FontType } from './fonts' 2 | export { roboto, FONT_TYPES } from './fonts' 3 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | . "$(dirname -- "$0")/common.sh" 4 | 5 | yarn pre-push 6 | -------------------------------------------------------------------------------- /src/core/Shared/infrastructure/wrappers/isUndefined.ts: -------------------------------------------------------------------------------- 1 | import isUndefined from 'lodash/isUndefined' 2 | 3 | export { isUndefined } 4 | -------------------------------------------------------------------------------- /src/ui/styles/tools/functions.scss: -------------------------------------------------------------------------------- 1 | @function toRem($value) { 2 | $remValue: ($value / 16) + rem; 3 | 4 | @return $remValue; 5 | } 6 | -------------------------------------------------------------------------------- /src/ui/styles/settings/index.scss: -------------------------------------------------------------------------------- 1 | @import 'colors'; 2 | @import 'shadows'; 3 | @import 'spacings'; 4 | @import 'grid'; 5 | @import 'z-indexes'; 6 | @import 'icons'; 7 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | . "$(dirname -- "$0")/common.sh" 4 | 5 | yarn type-check 6 | node node_modules/.bin/lint-staged 7 | -------------------------------------------------------------------------------- /.env.dist: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_THE_MOVIE_DB_BASE_URL=https://api.themoviedb.org/3/ 2 | NEXT_PUBLIC_THE_MOVIE_DB_IMAGE_URL=https://image.tmdb.org/t/p/ 3 | NEXT_PUBLIC_THE_MOVIE_DB_API_KEY= 4 | -------------------------------------------------------------------------------- /src/core/Movie/domain/Movies.repository.ts: -------------------------------------------------------------------------------- 1 | import { Movie } from '@/core/Movie/domain/Movie' 2 | 3 | export interface MoviesRepository { 4 | findPopular: () => Promise 5 | } 6 | -------------------------------------------------------------------------------- /src/app/page.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { PopularMovies } from '@/ui/views/PopularMovies' 4 | 5 | const PopularMoviesPage = () => 6 | 7 | export default PopularMoviesPage 8 | -------------------------------------------------------------------------------- /src/core/Shared/infrastructure/wrappers/isDefined.ts: -------------------------------------------------------------------------------- 1 | import { isUndefined } from './isUndefined' 2 | 3 | export const isDefined = (value: T | undefined): value is T => 4 | !isUndefined(value) 5 | -------------------------------------------------------------------------------- /src/_di/types.ts: -------------------------------------------------------------------------------- 1 | import { modules } from './modules' 2 | 3 | export type Modules = typeof modules 4 | 5 | export type Container = { 6 | [P in keyof Modules]: ReturnType 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/styles/settings/spacings.ts: -------------------------------------------------------------------------------- 1 | export type SpaceType = 2 | | 'xxs' 3 | | 'xs' 4 | | 's' 5 | | 'm' 6 | | 'l' 7 | | 'xl' 8 | | '2xl' 9 | | '3xl' 10 | | '4xl' 11 | | '5xl' 12 | -------------------------------------------------------------------------------- /.husky/common.sh: -------------------------------------------------------------------------------- 1 | command_exists () { 2 | command -v "$1" >/dev/null 2>&1 3 | } 4 | 5 | # Workaround for Windows 10, Git Bash and Yarn 6 | if command_exists winpty && test -t 1; then 7 | exec < /dev/tty 8 | fi -------------------------------------------------------------------------------- /src/ui/styles/settings/icons.ts: -------------------------------------------------------------------------------- 1 | export type IconSize = 2 | | '3xs' 3 | | '2xs' 4 | | 'xs' 5 | | 's' 6 | | 'm' 7 | | 'l' 8 | | 'xl' 9 | | '2xl' 10 | | '3xl' 11 | | '4xl' 12 | | '5xl' 13 | -------------------------------------------------------------------------------- /src/ui/components/atoms/Image/Image.tsx: -------------------------------------------------------------------------------- 1 | import NextImage, { ImageProps } from 'next/image' 2 | import { FC } from 'react' 3 | 4 | export const Image: FC = props => { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /src/_di/modules.ts: -------------------------------------------------------------------------------- 1 | import { modules as movieModules } from '@/core/Movie/_di' 2 | import { modules as sharedModules } from '@/core/Shared/_di' 3 | 4 | export const modules = { 5 | ...movieModules, 6 | ...sharedModules, 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/styles/objects/Flex/Flex.module.scss: -------------------------------------------------------------------------------- 1 | .flex { 2 | display: flex; 3 | gap: var(--gap); 4 | flex-direction: var(--flex-direction); 5 | justify-content: var(--justify-content); 6 | align-items: var(--align-items); 7 | } 8 | -------------------------------------------------------------------------------- /src/ui/styles/settings/z-indexes.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/functions'; 2 | 3 | :root { 4 | --z-index-1: 1; 5 | --z-index-2: 10; 6 | --z-index-3: 100; 7 | --z-index-4: 1000; 8 | --z-index-5: 10000; 9 | } 10 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "avoid", 3 | "bracketSpacing": true, 4 | "printWidth": 80, 5 | "semi": false, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "all", 9 | "useTabs": false 10 | } 11 | -------------------------------------------------------------------------------- /src/ui/styles/settings/breakpoints.ts: -------------------------------------------------------------------------------- 1 | export const BREAKPOINTS = { 2 | mobile: { max: 599 }, 3 | tablet: { min: 600, max: 1023 }, 4 | laptop: { min: 1024, max: 1311 }, 5 | desktop: { min: 1312 }, 6 | } 7 | 8 | export type BreakpointType = keyof typeof BREAKPOINTS 9 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | images: { 4 | remotePatterns: [ 5 | { 6 | protocol: 'https', 7 | hostname: 'image.tmdb.org', 8 | }, 9 | ], 10 | }, 11 | } 12 | 13 | module.exports = nextConfig 14 | -------------------------------------------------------------------------------- /src/core/Movie/infrastructure/PopularMoviesDTO.ts: -------------------------------------------------------------------------------- 1 | export interface PopularMoviesDTO { 2 | data: { 3 | results: Array<{ 4 | id: number 5 | title: string 6 | backdrop_path: string 7 | poster_path: string 8 | overview: string 9 | }> 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/ui/styles/settings/index.ts: -------------------------------------------------------------------------------- 1 | export type { Color } from './colors' 2 | export type { SpaceType } from './spacings' 3 | export type { FontType } from './fonts' 4 | export type { BreakpointType } from './breakpoints' 5 | export { BREAKPOINTS } from './breakpoints' 6 | export type { IconSize } from './icons' 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@typescript-eslint"], 3 | "extends": [ 4 | "next/core-web-vitals", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" 7 | ], 8 | "rules": { 9 | "@typescript-eslint/ban-ts-comment": "off", 10 | "@typescript-eslint/no-var-requires": "off" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/core/Shared/infrastructure/envManager.ts: -------------------------------------------------------------------------------- 1 | export const envManager = { 2 | getTmdbBaseUrl: () => process.env.NEXT_PUBLIC_THE_MOVIE_DB_BASE_URL, 3 | getTmbdApiKey: () => process.env.NEXT_PUBLIC_THE_MOVIE_DB_API_KEY, 4 | getTmdbImageUrl: () => process.env.NEXT_PUBLIC_THE_MOVIE_DB_IMAGE_URL, 5 | } 6 | 7 | export type EnvManager = typeof envManager 8 | -------------------------------------------------------------------------------- /src/core/Shared/_di/index.ts: -------------------------------------------------------------------------------- 1 | import { asClass, asValue } from 'awilix' 2 | import { TheMovieDBApiClient } from '@/core/Shared/infrastructure/TheMovieDBApiClient' 3 | import { envManager } from '@/core/Shared/infrastructure/envManager' 4 | 5 | export const modules = { 6 | apiClient: asClass(TheMovieDBApiClient), 7 | envManager: asValue(envManager), 8 | } 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Getting Started 2 | 3 | First, define the following environment variables in a `.env` file: 4 | 5 | - NEXT_PUBLIC_THE_MOVIE_DB_BASE_URL 6 | - NEXT_PUBLIC_THE_MOVIE_DB_API_KEY 7 | - NEXT_PUBLIC_THE_MOVIE_DB_IMAGE_URL 8 | 9 | Then, run the development server: 10 | 11 | ```bash 12 | npm run dev 13 | # or 14 | yarn dev 15 | # or 16 | pnpm dev 17 | # or 18 | bun dev 19 | ``` 20 | -------------------------------------------------------------------------------- /src/ui/__tests__/render.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactElement } from 'react' 2 | import type { RenderOptions, RenderResult } from '@testing-library/react' 3 | import { render as rtlRender } from '@testing-library/react' 4 | 5 | export const render = ( 6 | ui: ReactElement, 7 | options?: Omit, 8 | ): RenderResult => { 9 | return rtlRender(ui, options) 10 | } 11 | -------------------------------------------------------------------------------- /src/core/Movie/infrastructure/ImageSize.ts: -------------------------------------------------------------------------------- 1 | export const ImageSize = { 2 | backdrop: { 3 | w300: 'w300', 4 | w780: 'w780', 5 | w1280: 'w1280', 6 | original: 'original', 7 | }, 8 | poster: { 9 | w92: 'w92', 10 | w154: 'w154', 11 | w185: 'w185', 12 | w342: 'w342', 13 | w500: 'w500', 14 | w780: 'w780', 15 | original: 'original', 16 | }, 17 | } 18 | -------------------------------------------------------------------------------- /src/ui/styles/settings/shadows.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/functions'; 2 | 3 | :root { 4 | --shadow-soft: #{toRem(0)} #{toRem(4)} #{toRem(8)} rgba(0, 0, 0, 0.1); 5 | --shadow-mid: #{toRem(0)} #{toRem(4)} #{toRem(12)} rgba(0, 0, 0, 0.05); 6 | --shadow-hard: #{toRem(0)} #{toRem(6)} #{toRem(12)} rgba(0, 0, 0, 0.1); 7 | --shadow-inverted: #{toRem(0)} #{toRem(6)} #{toRem(12)} rgba(0, 0, 0, 0.1); 8 | } 9 | -------------------------------------------------------------------------------- /.lintstagedrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | const buildEslintCommand = filenames => 4 | `next lint --fix --file ${filenames 5 | .map(filename => path.relative(process.cwd(), filename)) 6 | .join(' --file ')}` 7 | 8 | module.exports = { 9 | '*.scss': ['stylelint --fix'], 10 | '*.{js,jsx,ts,tsx}': [ 11 | buildEslintCommand, 12 | 'prettier --write --loglevel silent', 13 | ], 14 | } 15 | -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@2/schema.json", 3 | "entry": ["src/**/*.{ts,tsx}"], 4 | "project": ["{src}/**/*.{js,jsx,ts,tsx}"], 5 | "exclude": ["enumMembers"], 6 | "ignoreBinaries": ["open", "xdg-open"], 7 | "ignoreDependencies": [ 8 | "eslint-config-next", 9 | "@testing-library/jest-dom", 10 | "@testing-library/user-event", 11 | "@types/jest", 12 | "jest-environment-jsdom" 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /src/app/layout.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/mixins/containers'; 2 | 3 | .layout { 4 | display: grid; 5 | grid-template-columns: 1fr; 6 | grid-template-rows: auto 1fr auto; 7 | 8 | width: 100%; 9 | min-height: 100vh; 10 | 11 | background-color: var(--color-bg-white); 12 | } 13 | 14 | .content { 15 | @include fullWidthContainerLateralPadding; 16 | width: 100%; 17 | 18 | background-color: var(--color-bg-white); 19 | } 20 | -------------------------------------------------------------------------------- /src/core/Movie/_di/index.ts: -------------------------------------------------------------------------------- 1 | import { asClass } from 'awilix' 2 | import { ApiMovieRepository } from '@/core/Movie/infrastructure/Movies.api.repository' 3 | import { GetPopularMoviesAndItsHighlight } from '@/core/Movie/usecase/GetPopularMoviesAndItsHighlight' 4 | 5 | export const modules = { 6 | moviesRepository: asClass(ApiMovieRepository), 7 | getPopularMoviesAndItsHighlightUseCase: asClass( 8 | GetPopularMoviesAndItsHighlight, 9 | ), 10 | } 11 | -------------------------------------------------------------------------------- /src/_di/container.ts: -------------------------------------------------------------------------------- 1 | import { Container } from './types' 2 | import { createContainer, InjectionMode } from 'awilix' 3 | import { modules } from './modules' 4 | 5 | export const container = createContainer({ 6 | injectionMode: InjectionMode.PROXY, 7 | }) 8 | 9 | export const inject = (module: T): Container[T] => 10 | container.resolve(module) 11 | 12 | export const registerModules = () => container.register({ ...modules }) 13 | -------------------------------------------------------------------------------- /src/core/Shared/infrastructure/TheMovieDBApiClient.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { ApiClient } from '@/core/Shared/infrastructure/ApiClient' 3 | import { envManager } from '@/core/Shared/infrastructure/envManager' 4 | 5 | export const theMovieDBAxiosInstance = axios.create({ 6 | baseURL: envManager.getTmdbBaseUrl(), 7 | }) 8 | 9 | export class TheMovieDBApiClient extends ApiClient { 10 | constructor() { 11 | super(theMovieDBAxiosInstance) 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/ui/styles/tools/mixins/gridLayout.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/mediaQueries'; 2 | 3 | @mixin gridLayout { 4 | display: grid; 5 | grid-auto-rows: minmax(min-content, max-content); 6 | grid-template-columns: repeat(4, 1fr); 7 | column-gap: var(--grid-mobile-gutter-gap); 8 | 9 | @media #{$fromLaptop} { 10 | grid-template-columns: repeat(12, 1fr); 11 | } 12 | 13 | @media #{$onlyDesktop} { 14 | column-gap: var(--grid-desktop-gutter-gap); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /jest.setup.integration.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom' 2 | import { asValue } from 'awilix' 3 | import { container } from '@/_di/container' 4 | import { modules } from '@/_di/modules' 5 | 6 | beforeEach(() => { 7 | container.register({ 8 | ...modules, 9 | envManager: asValue({ 10 | getTmdbBaseUrl: () => 'https://tmbd-base-url.com', 11 | getTmbdApiKey: () => 'api-key', 12 | getTmdbImageUrl: () => 'https://tmbd-image-url.com', 13 | }), 14 | }) 15 | }) 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | .idea 4 | 5 | # dependencies 6 | /node_modules 7 | /.pnp 8 | .pnp.js 9 | 10 | # testing 11 | /coverage 12 | 13 | # next.js 14 | /.next/ 15 | /out/ 16 | 17 | # production 18 | /build 19 | 20 | # misc 21 | .DS_Store 22 | *.pem 23 | 24 | # debug 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | 29 | # local env files 30 | .env*.local 31 | 32 | # vercel 33 | .vercel 34 | 35 | # typescript 36 | *.tsbuildinfo 37 | next-env.d.ts 38 | /.env 39 | -------------------------------------------------------------------------------- /src/ui/styles/settings/fonts/fonts.ts: -------------------------------------------------------------------------------- 1 | import { Roboto } from 'next/font/google' 2 | 3 | export const FONT_TYPES = [ 4 | 's-300', 5 | 's-500', 6 | 's-700', 7 | 'm-300', 8 | 'm-500', 9 | 'm-700', 10 | 'l-300', 11 | 'l-700', 12 | 'xl-300', 13 | 'xl-700', 14 | '2xl-700', 15 | '3xl-700', 16 | '4xl-500', 17 | ] as const 18 | 19 | export const roboto = Roboto({ 20 | weight: ['300', '500', '700'], 21 | style: ['normal'], 22 | subsets: ['latin'], 23 | display: 'swap', 24 | }) 25 | 26 | export type FontType = (typeof FONT_TYPES)[number] 27 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | # IDE 38 | .idea 39 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | 37 | # IDE 38 | .idea 39 | -------------------------------------------------------------------------------- /src/ui/styles/tools/mixins/containers.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/mediaQueries'; 2 | 3 | @mixin contentContainer { 4 | margin-left: auto; 5 | margin-right: auto; 6 | max-width: var(--grid-max-container); 7 | padding-left: var(--grid-lateral-gap); 8 | padding-right: var(--grid-lateral-gap); 9 | } 10 | 11 | @mixin fullWidthContainer { 12 | margin-left: calc(var(--grid-lateral-gap) * -1); 13 | margin-right: calc(var(--grid-lateral-gap) * -1); 14 | } 15 | 16 | @mixin fullWidthContainerLateralPadding { 17 | padding-left: var(--grid-lateral-gap); 18 | padding-right: var(--grid-lateral-gap); 19 | } 20 | -------------------------------------------------------------------------------- /src/app/layout.tsx: -------------------------------------------------------------------------------- 1 | 'use client' 2 | 3 | import { ReactNode } from 'react' 4 | import '@/ui/styles/itcss.scss' 5 | import styles from './layout.module.scss' 6 | import { roboto } from '@/ui/styles/settings/fonts' 7 | import { registerModules } from '@/_di/container' 8 | 9 | interface Props { 10 | children: ReactNode 11 | } 12 | 13 | registerModules() 14 | 15 | const RootLayout = ({ children }: Props) => ( 16 | 17 | 18 |
{children}
19 | 20 | 21 | ) 22 | 23 | export default RootLayout 24 | -------------------------------------------------------------------------------- /src/ui/utils/classNames.ts: -------------------------------------------------------------------------------- 1 | type classNameProp = 2 | | string 3 | | { [key: string]: boolean } 4 | | null 5 | | undefined 6 | | false 7 | 8 | export const classNames = (...classNames: classNameProp[]): string => { 9 | const resultClasses: string[] = [] 10 | 11 | classNames.forEach(className => { 12 | if (!className) return 13 | 14 | if (typeof className === 'string') { 15 | return resultClasses.push(className) 16 | } 17 | 18 | Object.keys(className).forEach(key => { 19 | if (className[key]) { 20 | resultClasses.push(key) 21 | } 22 | }) 23 | }) 24 | 25 | return resultClasses.join(' ') 26 | } 27 | -------------------------------------------------------------------------------- /jest.config.unit.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest') 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: './', 6 | }) 7 | 8 | // Add any custom config to be passed to Jest 9 | const customJestConfig = { 10 | testRegex: '(?/'], 13 | testPathIgnorePatterns: ['/cypress/'], 14 | } 15 | 16 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 17 | module.exports = createJestConfig(customJestConfig) 18 | -------------------------------------------------------------------------------- /jest.config.integration.js: -------------------------------------------------------------------------------- 1 | const nextJest = require('next/jest') 2 | 3 | const createJestConfig = nextJest({ 4 | // Provide the path to your Next.js app to load next.config.js and .env files in your test environment 5 | dir: './', 6 | }) 7 | 8 | // Add any custom config to be passed to Jest 9 | const customJestConfig = { 10 | testRegex: ['\\.test\\.(tsx)$', '\\.integration\\.test\\.ts$'], 11 | testEnvironment: 'jest-environment-jsdom', 12 | moduleDirectories: ['node_modules', '/'], 13 | setupFilesAfterEnv: ['/jest.setup.integration.ts'], 14 | } 15 | 16 | // createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async 17 | module.exports = createJestConfig(customJestConfig) 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "noEmit": true, 9 | "esModuleInterop": true, 10 | "module": "esnext", 11 | "moduleResolution": "bundler", 12 | "resolveJsonModule": true, 13 | "isolatedModules": true, 14 | "jsx": "preserve", 15 | "incremental": true, 16 | "plugins": [ 17 | { 18 | "name": "next" 19 | } 20 | ], 21 | "paths": { 22 | "@/*": ["./src/*"] 23 | } 24 | }, 25 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "./jest.setup.integration.ts"], 26 | "exclude": ["node_modules"] 27 | } 28 | -------------------------------------------------------------------------------- /src/ui/styles/tools/mediaQueries.scss: -------------------------------------------------------------------------------- 1 | // https://stackoverflow.com/questions/70120238/do-i-consider-decimal-point-of-px-in-media-query 2 | $mobileMax: 599.98px; 3 | $tabletMin: 600px; 4 | $tabletMax: 1023.98px; 5 | $laptopMin: 1024px; 6 | $laptopMax: 1311.98px; 7 | $desktopMin: 1312px; 8 | 9 | $onlyMobile: 'only screen and (max-width: ' + $mobileMax + ')'; 10 | $onlyTablet: 'only screen and (min-width: ' + $tabletMin + ') AND (max-width: ' + 11 | $tabletMax + ')'; 12 | $onlyLaptop: 'only screen and (min-width: ' + $laptopMin + ') AND (max-width: ' + 13 | $laptopMax + ')'; 14 | $onlyDesktop: 'only screen and (min-width: ' + $desktopMin + ')'; 15 | 16 | $fromTablet: 'only screen and (min-width: ' + $tabletMin + ')'; 17 | $fromLaptop: 'only screen and (min-width: ' + $laptopMin + ')'; 18 | -------------------------------------------------------------------------------- /src/ui/styles/settings/colors.ts: -------------------------------------------------------------------------------- 1 | export type Color = 2 | | 'support-success' 3 | | 'support-success-light' 4 | | 'support-warning' 5 | | 'support-warning-light' 6 | | 'support-error' 7 | | 'support-error-light' 8 | | 'support-info' 9 | | 'support-info-light' 10 | | 'bg-light' 11 | | 'bg-white' 12 | | 'bg-overlay' 13 | | 'fill-neutral-00' 14 | | 'fill-neutral-01' 15 | | 'fill-neutral-02' 16 | | 'fill-neutral-03' 17 | | 'fill-neutral-04' 18 | | 'fill-ocean-100' 19 | | 'fill-ocean-15' 20 | | 'fill-ocean-20' 21 | | 'fill-ocean-10' 22 | | 'fill-ocean-100-gradient' 23 | | 'divider' 24 | | 'text-primary' 25 | | 'text-light' 26 | | 'text-mid' 27 | | 'text-dark' 28 | | 'text-disabled' 29 | | 'field-enabled' 30 | | 'field-hover' 31 | | 'field-active' 32 | | 'field-disabled' 33 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm) 2 | // chore: Changes to our build process or auxiliary tools (example scopes: grunt, gulp, broccoli, npm) 3 | // ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs) 4 | // docs: Documentation only changes 5 | // feat: A new feature 6 | // fix: A bug fix 7 | // perf: A code change that improves performance 8 | // refactor: A code change that neither fixes a bug nor adds a feature 9 | // revert: Revert to a commit 10 | // style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 11 | // test: Adding missing tests or correcting existing tests 12 | 13 | module.exports = { 14 | extends: ['@commitlint/config-conventional'], 15 | } 16 | -------------------------------------------------------------------------------- /src/core/Movie/usecase/GetPopularMoviesAndItsHighlight.ts: -------------------------------------------------------------------------------- 1 | import { MoviesRepository } from '@/core/Movie/domain/Movies.repository' 2 | import { Movie } from '@/core/Movie/domain/Movie' 3 | 4 | interface Depedenencies { 5 | moviesRepository: MoviesRepository 6 | } 7 | 8 | export class GetPopularMoviesAndItsHighlight { 9 | private moviesRepository: MoviesRepository 10 | 11 | constructor({ moviesRepository }: Depedenencies) { 12 | this.moviesRepository = moviesRepository 13 | } 14 | 15 | async execute(): Promise<{ 16 | popularMovies: Movie[] 17 | highlightedMovie: Movie 18 | }> { 19 | const popularMovies = await this.moviesRepository.findPopular() 20 | const nextPopularMovies = popularMovies.filter( 21 | movie => movie.id !== popularMovies[0].id, 22 | ) 23 | 24 | return { 25 | highlightedMovie: popularMovies[0], 26 | popularMovies: nextPopularMovies, 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/ui/styles/settings/spacings.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/functions'; 2 | 3 | @function calculateFromGrid($grid-base, $multiplier) { 4 | $result: $grid-base * $multiplier; 5 | 6 | @return $result; 7 | } 8 | 9 | :root { 10 | $grid-base: 8; 11 | 12 | --space-xxs: #{toRem(calculateFromGrid($grid-base, 0.5))}; // 4px 13 | --space-xs: #{toRem(calculateFromGrid($grid-base, 1))}; // 8px 14 | --space-s: #{toRem(calculateFromGrid($grid-base, 1.5))}; // 12px 15 | --space-m: #{toRem(calculateFromGrid($grid-base, 2))}; // 16px 16 | --space-l: #{toRem(calculateFromGrid($grid-base, 3))}; // 24px 17 | --space-xl: #{toRem(calculateFromGrid($grid-base, 4))}; // 32px 18 | --space-2xl: #{toRem(calculateFromGrid($grid-base, 5))}; // 40px 19 | --space-3xl: #{toRem(calculateFromGrid($grid-base, 6))}; // 48px 20 | --space-4xl: #{toRem(calculateFromGrid($grid-base, 8))}; // 64px 21 | --space-5xl: #{toRem(calculateFromGrid($grid-base, 12))}; // 96px 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/styles/settings/icons.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/functions'; 2 | 3 | @function calculateFromGrid($grid-base, $multiplier) { 4 | $result: $grid-base * $multiplier; 5 | 6 | @return $result; 7 | } 8 | 9 | :root { 10 | $grid-base: 8; 11 | 12 | --icon-3xs: #{toRem(calculateFromGrid($grid-base, 0.5))}; // 4px 13 | --icon-2xs: #{toRem(calculateFromGrid($grid-base, 1))}; // 8px 14 | --icon-xs: #{toRem(calculateFromGrid($grid-base, 1.5))}; // 12px 15 | --icon-s: #{toRem(calculateFromGrid($grid-base, 2))}; // 16px 16 | --icon-m: #{toRem(calculateFromGrid($grid-base, 2.5))}; // 20px 17 | --icon-l: #{toRem(calculateFromGrid($grid-base, 3))}; // 24px 18 | --icon-xl: #{toRem(calculateFromGrid($grid-base, 4))}; // 32px 19 | --icon-2xl: #{toRem(calculateFromGrid($grid-base, 5))}; // 40px 20 | --icon-3xl: #{toRem(calculateFromGrid($grid-base, 6))}; // 48px 21 | --icon-4xl: #{toRem(calculateFromGrid($grid-base, 8))}; // 64px 22 | --icon-5xl: #{toRem(calculateFromGrid($grid-base, 12))}; // 96px 23 | } 24 | -------------------------------------------------------------------------------- /src/core/Movie/domain/Movie.ts: -------------------------------------------------------------------------------- 1 | export class Movie { 2 | private _id: number 3 | private _title: string 4 | private _backdropUrl: string 5 | private _posterUrl: string 6 | private _description: string 7 | 8 | constructor({ 9 | id, 10 | title, 11 | backdropUrl, 12 | posterUrl, 13 | description, 14 | }: { 15 | id: Movie['_id'] 16 | title: Movie['_title'] 17 | backdropUrl: Movie['_backdropUrl'] 18 | posterUrl: Movie['_posterUrl'] 19 | description: Movie['_description'] 20 | }) { 21 | this._id = id 22 | this._title = title 23 | this._backdropUrl = backdropUrl 24 | this._posterUrl = posterUrl 25 | this._description = description 26 | } 27 | 28 | get id(): number { 29 | return this._id 30 | } 31 | 32 | get title(): string { 33 | return this._title 34 | } 35 | 36 | get backdropUrl(): string { 37 | return this._backdropUrl 38 | } 39 | 40 | get posterUrl(): string { 41 | return this._posterUrl 42 | } 43 | 44 | get description(): string { 45 | return this._description 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/styles/settings/grid.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/functions'; 2 | @import 'src/ui/styles/tools/mediaQueries'; 3 | 4 | :root { 5 | --footer-height: #{toRem(74)}; 6 | --header-height: #{toRem(144)}; 7 | 8 | --grid-max-container: #{toRem(1312)}; 9 | 10 | --grid-mobile-lateral-gap: #{toRem(16)}; 11 | --grid-tablet-lateral-gap: #{toRem(24)}; 12 | --grid-laptop-lateral-gap: #{toRem(32)}; 13 | --grid-desktop-lateral-gap: #{toRem(32)}; 14 | --grid-full-container-desktop-lateral-gap: calc( 15 | calc(100vw - var(--grid-max-container)) / 2 + 16 | var(--grid-desktop-lateral-gap) 17 | ); 18 | 19 | --grid-mobile-gutter-gap: #{toRem(16)}; 20 | --grid-desktop-gutter-gap: #{toRem(24)}; 21 | 22 | --grid-lateral-gap: var(--grid-mobile-lateral-gap); 23 | 24 | @media #{$fromTablet} { 25 | --grid-lateral-gap: var(--grid-tablet-lateral-gap); 26 | } 27 | 28 | @media #{$fromLaptop} { 29 | --grid-lateral-gap: var(--grid-laptop-lateral-gap); 30 | } 31 | 32 | @media #{$onlyDesktop} { 33 | --grid-lateral-gap: var(--grid-full-container-desktop-lateral-gap); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/core/Movie/usecase/__tests__/GetPopularMoviesAndItsHighlight.test.ts: -------------------------------------------------------------------------------- 1 | import { mock } from 'jest-mock-extended' 2 | import { GetPopularMoviesAndItsHighlight } from '@/core/Movie/usecase/GetPopularMoviesAndItsHighlight' 3 | import { MoviesRepository } from '@/core/Movie/domain/Movies.repository' 4 | import { MovieBuilder } from '@/core/Movie/domain/__mocks__/MovieBuilder' 5 | 6 | describe('Caso de uso de recoger películas populares y su destacada', () => { 7 | it('las obtiene y calcula la destacada', async () => { 8 | const firstMovie = new MovieBuilder() 9 | const secondMovie = new MovieBuilder().withId(2) 10 | const moviesRepositoryMock = mock() 11 | moviesRepositoryMock.findPopular.mockResolvedValue([ 12 | firstMovie.build(), 13 | secondMovie.build(), 14 | ]) 15 | 16 | const popularMovies = await new GetPopularMoviesAndItsHighlight({ 17 | moviesRepository: moviesRepositoryMock, 18 | }).execute() 19 | 20 | expect(popularMovies).toEqual({ 21 | highlightedMovie: firstMovie.build(), 22 | popularMovies: [secondMovie.build()], 23 | }) 24 | }) 25 | }) 26 | -------------------------------------------------------------------------------- /src/ui/styles/objects/Flex/Flex.tsx: -------------------------------------------------------------------------------- 1 | import { CSSProperties, FC, ReactNode } from 'react' 2 | import styles from './Flex.module.scss' 3 | import { SpaceType } from '@/ui/styles/settings' 4 | import { classNames } from '@/ui/utils/classNames' 5 | 6 | interface Props { 7 | direction?: 'row' | 'column' | 'row-reverse' 8 | justifyContent?: 9 | | 'flex-start' 10 | | 'flex-end' 11 | | 'center' 12 | | 'space-around' 13 | | 'space-between' 14 | | 'space-evenly' 15 | alignItems?: 'baseline' | 'center' | 'flex-start' | 'flex-end' | 'stretch' 16 | gap?: SpaceType 17 | children: ReactNode 18 | className?: string 19 | } 20 | 21 | export const Flex: FC = ({ 22 | direction = 'row', 23 | justifyContent = 'flex-start', 24 | alignItems = 'flex-start', 25 | gap = 0, 26 | children, 27 | className, 28 | }) => { 29 | const styleProps = { 30 | '--flex-direction': direction, 31 | '--justify-content': justifyContent, 32 | '--align-items': alignItems, 33 | '--gap': gap && `var(--space-${gap})`, 34 | } as CSSProperties 35 | 36 | return ( 37 |
38 | {children} 39 |
40 | ) 41 | } 42 | -------------------------------------------------------------------------------- /src/_di/mocks/registerMockModule.ts: -------------------------------------------------------------------------------- 1 | import { Container, Modules } from '../types' 2 | import { container } from '../container' 3 | import { asFunction } from '../resolvers' 4 | import { mock, MockProxy } from 'jest-mock-extended' 5 | import { modules } from '@/_di/modules' 6 | import { Resolver } from 'awilix/lib/resolvers' 7 | 8 | const registerMockModule = ( 9 | moduleName: ModuleName, 10 | ): MockProxy => { 11 | const mockedModule = mock() 12 | 13 | container.register({ 14 | [moduleName]: asFunction(() => mockedModule), 15 | }) 16 | 17 | return mockedModule 18 | } 19 | 20 | export type MockedModules = { 21 | [ModuleName in keyof Modules]: MockProxy 22 | } 23 | 24 | export const registerMockModules = () => { 25 | const moduleNames = Object.keys(modules) as (keyof typeof modules)[] 26 | return Object.fromEntries( 27 | moduleNames.map(moduleName => [moduleName, registerMockModule(moduleName)]), 28 | ) as MockedModules 29 | } 30 | 31 | export const registerModule = ( 32 | moduleName: ModuleName, 33 | module: Resolver, 34 | ): void => { 35 | container.register({ [moduleName]: module }) 36 | } 37 | -------------------------------------------------------------------------------- /src/core/Movie/infrastructure/Movies.api.repository.ts: -------------------------------------------------------------------------------- 1 | import { MoviesRepository } from '@/core/Movie/domain/Movies.repository' 2 | import { ApiClient } from '@/core/Shared/infrastructure/ApiClient' 3 | import { Movie } from '@/core/Movie/domain/Movie' 4 | import { PopularMoviesDTO } from '@/core/Movie/infrastructure/PopularMoviesDTO' 5 | import type { EnvManager } from '@/core/Shared/infrastructure/envManager' 6 | 7 | interface Dependencies { 8 | apiClient: ApiClient 9 | envManager: EnvManager 10 | } 11 | 12 | export class ApiMovieRepository implements MoviesRepository { 13 | private apiClient: ApiClient 14 | private envManager: EnvManager 15 | 16 | constructor({ apiClient, envManager }: Dependencies) { 17 | this.apiClient = apiClient 18 | this.envManager = envManager 19 | } 20 | 21 | async findPopular() { 22 | const moviesDTO = await this.apiClient.get( 23 | `/movie/popular?api_key=${this.envManager.getTmbdApiKey()}&language=en-EN`, 24 | ) 25 | 26 | return moviesDTO.data.results.map( 27 | movieDTO => 28 | new Movie({ 29 | id: movieDTO.id, 30 | title: movieDTO.title, 31 | backdropUrl: movieDTO.backdrop_path, 32 | posterUrl: movieDTO.poster_path, 33 | description: movieDTO.overview, 34 | }), 35 | ) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/ui/styles/settings/colors.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --color-support-success: #197625; 3 | --color-support-success-light: #d9e8db; 4 | --color-support-warning: #ffc83d; 5 | --color-support-warning-light: #ffedbe; 6 | --color-support-error: #e40f13; 7 | --color-support-error-light: #fbd7d8; 8 | --color-support-info: #0091f0; 9 | --color-support-info-light: #e5f4fe; 10 | 11 | --color-bg-light: #f5f5f6; 12 | --color-bg-white: #ffffff; 13 | --color-bg-overlay: rgba(56, 64, 68, 0.7); 14 | 15 | --color-fill-neutral-00: #ffffff; 16 | --color-fill-neutral-01: #f5f5f6; 17 | --color-fill-neutral-02: #d7d9da; 18 | --color-fill-neutral-03: #afb3b4; 19 | --color-fill-neutral-04: #384044; 20 | --color-fill-ocean-100: #2f5a76; 21 | --color-fill-ocean-15: #e0e6ea; 22 | --color-fill-ocean-20: #acbdc8; 23 | --color-fill-ocean-10: #eaeef1; 24 | --color-fill-ocean-100-gradient: linear-gradient( 25 | 99.82deg, 26 | #30a2bf 0%, 27 | #2f5a76 100% 28 | ); 29 | 30 | --color-divider: #d7d9da; 31 | 32 | --color-text-primary: #2f5a76; 33 | --color-text-light: #ffffff; 34 | --color-text-mid: #606669; 35 | --color-text-dark: #384044; 36 | --color-text-disabled: #afb3b4; 37 | 38 | --color-field-enabled: #384044; 39 | --color-field-hover: #384044; 40 | --color-field-active: #2f5a76; 41 | --color-field-disabled: #afb3b4; 42 | } 43 | -------------------------------------------------------------------------------- /src/core/Movie/domain/__mocks__/MovieBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Movie } from '@/core/Movie/domain/Movie' 2 | 3 | export class MovieBuilder { 4 | private _id: number = 1 5 | private _title: string = 'title' 6 | private _backdropUrl: string = 'backdropUrl' 7 | private _posterUrl: string = 'posterUrl' 8 | private _description: string = 'description' 9 | 10 | withId(id: number): this { 11 | this._id = id 12 | return this 13 | } 14 | 15 | withTitle(title: string): this { 16 | this._title = title 17 | return this 18 | } 19 | 20 | withBackdropUrl(backdropUrl: string): this { 21 | this._backdropUrl = backdropUrl 22 | return this 23 | } 24 | 25 | withPosterUrl(posterUrl: string): this { 26 | this._posterUrl = posterUrl 27 | return this 28 | } 29 | 30 | withDescription(description: string): this { 31 | this._description = description 32 | return this 33 | } 34 | 35 | build(): Movie { 36 | return new Movie({ 37 | id: this._id, 38 | title: this._title, 39 | backdropUrl: this._backdropUrl, 40 | posterUrl: this._posterUrl, 41 | description: this._description, 42 | }) 43 | } 44 | 45 | buildSingleList(): Movie[] { 46 | return [ 47 | new Movie({ 48 | id: this._id, 49 | title: this._title, 50 | backdropUrl: this._backdropUrl, 51 | posterUrl: this._posterUrl, 52 | description: this._description, 53 | }), 54 | ] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/views/PopularMovies/PopularMovies.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/mixins/containers'; 2 | @import 'src/ui/styles/tools/functions'; 3 | 4 | .backdropContainer { 5 | @include fullWidthContainer; 6 | 7 | position: relative; 8 | 9 | min-width: 100dvw; 10 | height: toRem(600); 11 | } 12 | 13 | .backdrop { 14 | width: 100%; 15 | height: 100%; 16 | 17 | object-fit: cover; 18 | } 19 | 20 | .backdropTitleContainer { 21 | position: absolute; 22 | z-index: 100; 23 | top: 50%; 24 | left: var(--grid-lateral-gap); 25 | 26 | transform: translateY(-50%); 27 | 28 | max-width: 35%; 29 | 30 | text-shadow: 0 0 toRem(10) rgba(0, 0, 0, 0.75); 31 | } 32 | 33 | .backdropTitle { 34 | display: -webkit-box; 35 | overflow: hidden; 36 | 37 | -webkit-line-clamp: 4; 38 | -webkit-box-orient: vertical; 39 | } 40 | 41 | .popularMoviesTitle { 42 | margin-top: var(--space-2xl); 43 | margin-bottom: var(--space-l); 44 | } 45 | 46 | .grid { 47 | display: grid; 48 | grid-template-columns: repeat(2, 1fr); 49 | grid-column-gap: var(--space-m); 50 | grid-row-gap: var(--space-xl); 51 | 52 | @media #{$fromTablet} { 53 | grid-template-columns: repeat(3, 1fr); 54 | } 55 | 56 | @media #{$fromLaptop} { 57 | grid-template-columns: repeat(4, 1fr); 58 | } 59 | } 60 | 61 | .cardContainer { 62 | display: flex; 63 | gap: var(--space-xs); 64 | flex-direction: column; 65 | justify-content: center; 66 | align-items: center; 67 | } 68 | -------------------------------------------------------------------------------- /src/core/Movie/infrastructure/__mocks__/PopularMoviesDTOBuilder.ts: -------------------------------------------------------------------------------- 1 | import { PopularMoviesDTO } from '@/core/Movie/infrastructure/PopularMoviesDTO' 2 | 3 | export class PopularMoviesDTOBuilder { 4 | private _id: number 5 | private _title: string 6 | private _backdrop_path: string 7 | private _poster_path: string 8 | private _overview: string 9 | 10 | constructor() { 11 | this._id = 1 12 | this._title = 'title' 13 | this._backdrop_path = 'backdrop_path' 14 | this._poster_path = 'poster_path' 15 | this._overview = 'overview' 16 | } 17 | 18 | withId(id: number): PopularMoviesDTOBuilder { 19 | this._id = id 20 | return this 21 | } 22 | 23 | withTitle(title: string): PopularMoviesDTOBuilder { 24 | this._title = title 25 | return this 26 | } 27 | 28 | withBackdropPath(backdrop_path: string): PopularMoviesDTOBuilder { 29 | this._backdrop_path = backdrop_path 30 | return this 31 | } 32 | 33 | withPosterPath(poster_path: string): PopularMoviesDTOBuilder { 34 | this._poster_path = poster_path 35 | return this 36 | } 37 | 38 | withOverview(overview: string): PopularMoviesDTOBuilder { 39 | this._overview = overview 40 | return this 41 | } 42 | 43 | build(): PopularMoviesDTO { 44 | return { 45 | data: { 46 | results: [ 47 | { 48 | id: this._id, 49 | title: this._title, 50 | backdrop_path: this._backdrop_path, 51 | poster_path: this._poster_path, 52 | overview: this._overview, 53 | }, 54 | ], 55 | }, 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/core/Movie/infrastructure/__tests__/Movies.api.repository.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, MockProxy } from 'jest-mock-extended' 2 | import { ApiMovieRepository } from '@/core/Movie/infrastructure/Movies.api.repository' 3 | import { ApiClient } from '@/core/Shared/infrastructure/ApiClient' 4 | import { PopularMoviesDTOBuilder } from '@/core/Movie/infrastructure/__mocks__/PopularMoviesDTOBuilder' 5 | import { MovieBuilder } from '@/core/Movie/domain/__mocks__/MovieBuilder' 6 | import type { EnvManager } from '@/core/Shared/infrastructure/envManager' 7 | 8 | describe('repositorio de películas', () => { 9 | let mockApiClient: MockProxy 10 | let mockEnvManager: MockProxy 11 | 12 | beforeEach(() => { 13 | mockApiClient = mock() 14 | mockEnvManager = mock() 15 | }) 16 | 17 | it('encuentra las películas populares', async () => { 18 | mockApiClient.get.mockResolvedValue( 19 | new PopularMoviesDTOBuilder() 20 | .withId(13) 21 | .withTitle('Any title') 22 | .withBackdropPath('Any backdrop path') 23 | .withPosterPath('Any poster path') 24 | .withOverview('Any overview') 25 | .build(), 26 | ) 27 | const repository = new ApiMovieRepository({ 28 | apiClient: mockApiClient, 29 | envManager: mockEnvManager, 30 | }) 31 | 32 | const popularMovies = await repository.findPopular() 33 | 34 | expect(popularMovies).toEqual( 35 | new MovieBuilder() 36 | .withId(13) 37 | .withTitle('Any title') 38 | .withBackdropUrl('Any backdrop path') 39 | .withPosterUrl('Any poster path') 40 | .withDescription('Any overview') 41 | .buildSingleList(), 42 | ) 43 | }) 44 | }) 45 | -------------------------------------------------------------------------------- /scripts/pre-push.js: -------------------------------------------------------------------------------- 1 | const spawn = require('cross-spawn') 2 | const colors = require('colors') 3 | 4 | ;(async function () { 5 | /** 6 | * Check git staged files that are not commited. 7 | * This is important because tests may be passing locally and 8 | * failing when running in CI. 9 | */ 10 | const gitResult = await exec('git diff HEAD --quiet') 11 | if (gitResult !== 0) { 12 | console.log( 13 | colors.bold.red('You must not have any uncommitted files prior to push.'), 14 | ) 15 | process.exit(1) 16 | } 17 | 18 | /** 19 | * Run unit tests 20 | */ 21 | process.env.TZ = 'UTC' 22 | const unitTestResult = await exec( 23 | 'yarn jest -c jest.config.unit.js --bail --maxWorkers=40%', 24 | ) 25 | if (unitTestResult !== 0) { 26 | process.exit(1) 27 | } 28 | 29 | /** 30 | * Run integration tests 31 | */ 32 | const integrationTestResult = await exec( 33 | 'yarn jest -c jest.config.integration.js --bail --maxWorkers=40%', 34 | ) 35 | if (integrationTestResult !== 0) { 36 | process.exit(1) 37 | } 38 | 39 | /** 40 | * Run dependencies analyzer 41 | */ 42 | const dependenciesAnalyzerResult = await exec('yarn knip') 43 | if (dependenciesAnalyzerResult !== 0) { 44 | console.log('\n⚠️ TE HAS DEJADO ALGO (revisa los logs de "knip") ⚠️\n') 45 | } 46 | })() 47 | function exec(cmd) { 48 | const cmdParts = cmd.split(' ') 49 | const cmdCommand = cmdParts[0] 50 | const cmdArguments = cmdParts.splice(1) 51 | 52 | return new Promise(resolve => { 53 | const process = spawn(cmdCommand, cmdArguments, { 54 | stdio: 'inherit', 55 | }) 56 | process.on('close', function (code) { 57 | resolve(code) 58 | }) 59 | }) 60 | } 61 | 62 | process.on('unhandledRejection', error => { 63 | console.log(error) 64 | process.exit(1) 65 | }) 66 | -------------------------------------------------------------------------------- /src/ui/styles/generic/reset.scss: -------------------------------------------------------------------------------- 1 | /* http://meyerweb.com/eric/tools/css/reset/ 2 | v2.0 | 20110126 3 | License: none (public domain) 4 | */ 5 | 6 | html, 7 | body, 8 | div, 9 | span, 10 | applet, 11 | object, 12 | iframe, 13 | h1, 14 | h2, 15 | h3, 16 | h4, 17 | h5, 18 | h6, 19 | p, 20 | blockquote, 21 | pre, 22 | a, 23 | abbr, 24 | acronym, 25 | address, 26 | big, 27 | cite, 28 | code, 29 | del, 30 | dfn, 31 | em, 32 | img, 33 | ins, 34 | kbd, 35 | q, 36 | s, 37 | samp, 38 | small, 39 | strike, 40 | strong, 41 | sub, 42 | sup, 43 | tt, 44 | var, 45 | b, 46 | u, 47 | i, 48 | center, 49 | dl, 50 | dt, 51 | dd, 52 | ol, 53 | ul, 54 | li, 55 | fieldset, 56 | form, 57 | label, 58 | legend, 59 | table, 60 | caption, 61 | tbody, 62 | tfoot, 63 | thead, 64 | tr, 65 | th, 66 | td, 67 | article, 68 | aside, 69 | canvas, 70 | details, 71 | embed, 72 | figure, 73 | figcaption, 74 | footer, 75 | header, 76 | hgroup, 77 | menu, 78 | nav, 79 | output, 80 | ruby, 81 | section, 82 | summary, 83 | time, 84 | mark, 85 | audio, 86 | video { 87 | margin: 0; 88 | border: 0; 89 | padding: 0; 90 | 91 | font: inherit; 92 | font-size: 100%; 93 | vertical-align: baseline; 94 | } 95 | /* HTML5 display-role reset for older browsers */ 96 | article, 97 | aside, 98 | details, 99 | figcaption, 100 | figure, 101 | footer, 102 | header, 103 | hgroup, 104 | menu, 105 | nav, 106 | section { 107 | display: block; 108 | } 109 | body { 110 | line-height: 1; 111 | font-family: 112 | -apple-system, 113 | BlinkMacSystemFont, 114 | Segoe UI, 115 | Roboto, 116 | Oxygen, 117 | Ubuntu, 118 | Cantarell, 119 | Fira Sans, 120 | Droid Sans, 121 | Helvetica Neue, 122 | sans-serif; 123 | } 124 | ol, 125 | ul { 126 | list-style: none; 127 | } 128 | blockquote, 129 | q { 130 | quotes: none; 131 | } 132 | blockquote:before, 133 | blockquote:after, 134 | q:before, 135 | q:after { 136 | content: ''; 137 | content: none; 138 | } 139 | table { 140 | border-collapse: collapse; 141 | border-spacing: 0; 142 | } 143 | /* Button text align reset */ 144 | button { 145 | text-align: unset; 146 | } 147 | -------------------------------------------------------------------------------- /src/ui/components/atoms/Text/Text.tsx: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react' 2 | import styles from './Text.module.scss' 3 | import { FontType } from '@/ui/styles/settings' 4 | import { classNames } from '@/ui/utils/classNames' 5 | 6 | export type TextColor = 7 | | 'light' 8 | | 'mid' 9 | | 'dark' 10 | | 'disabled' 11 | | 'support-success' 12 | | 'support-error' 13 | | 'secondary' 14 | | 'primary' 15 | | 'fill-neutral-03' 16 | 17 | interface Props { 18 | fontStyle: 19 | | FontType 20 | | { 21 | mobile: FontType 22 | tablet?: FontType 23 | laptop?: FontType 24 | desktop?: FontType 25 | } 26 | color?: TextColor 27 | uppercase?: boolean 28 | centered?: boolean 29 | className?: string 30 | children?: ReactNode 31 | as?: 'span' | 'p' | 'div' | 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' 32 | ariaLabel?: string 33 | 'data-testid'?: string 34 | } 35 | 36 | export const Text: FC = ({ 37 | fontStyle: style, 38 | color, 39 | uppercase, 40 | centered, 41 | className, 42 | children, 43 | as: Tag = 'span', 44 | ariaLabel, 45 | 'data-testid': testId, 46 | }) => { 47 | const getClassName = () => { 48 | if (typeof style === 'string') { 49 | return classNames( 50 | styles[`mobile-${style}`], 51 | styles[`tablet-${style}`], 52 | styles[`laptop-${style}`], 53 | styles[`desktop-${style}`], 54 | styles[`color-${color}`], 55 | uppercase && styles.toUppercase, 56 | centered && styles.centered, 57 | className, 58 | ) 59 | } 60 | 61 | const mobile = style.mobile 62 | const tablet = style.tablet || mobile 63 | const laptop = style.laptop || tablet 64 | const desktop = style.desktop || laptop 65 | 66 | return classNames( 67 | styles[`mobile-${mobile}`], 68 | styles[`tablet-${tablet}`], 69 | styles[`laptop-${laptop}`], 70 | styles[`desktop-${desktop}`], 71 | styles[`color-${color}`], 72 | centered && styles.centered, 73 | className, 74 | ) 75 | } 76 | 77 | return ( 78 | 79 | {children} 80 | 81 | ) 82 | } 83 | -------------------------------------------------------------------------------- /src/ui/views/PopularMovies/__tests__/PopularMovies.test.tsx: -------------------------------------------------------------------------------- 1 | import { asValue } from 'awilix' 2 | import { screen } from '@testing-library/react' 3 | import { mock, MockProxy } from 'jest-mock-extended' 4 | import { render } from '@/ui/__tests__/render' 5 | import { GetPopularMoviesAndItsHighlight } from '@/core/Movie/usecase/GetPopularMoviesAndItsHighlight' 6 | import { MovieBuilder } from '@/core/Movie/domain/__mocks__/MovieBuilder' 7 | import { PopularMovies } from '@/ui/views/PopularMovies' 8 | import { registerModule } from '@/_di/mocks/registerMockModule' 9 | 10 | describe('Películas populares', () => { 11 | let mockGetPopularMoviesAndItsHighlightUseCase: MockProxy 12 | 13 | beforeEach(() => { 14 | mockGetPopularMoviesAndItsHighlightUseCase = 15 | mock() 16 | 17 | registerModule( 18 | 'getPopularMoviesAndItsHighlightUseCase', 19 | asValue(mockGetPopularMoviesAndItsHighlightUseCase), 20 | ) 21 | }) 22 | 23 | it('muestra la destacada y no el resto cuando no hay otras', async () => { 24 | const highlightedMovie = new MovieBuilder().build() 25 | mockGetPopularMoviesAndItsHighlightUseCase.execute.mockResolvedValue({ 26 | highlightedMovie, 27 | popularMovies: [], 28 | }) 29 | 30 | render() 31 | 32 | expect(await screen.findByText(highlightedMovie.title)).toBeInTheDocument() 33 | }) 34 | 35 | it('muestra varias cuando existen', async () => { 36 | const highlightedMovie = new MovieBuilder() 37 | .withTitle('Highlighted Movie') 38 | .build() 39 | const popularMovies = [ 40 | new MovieBuilder().withId(1).withTitle('Popular Movie 1').build(), 41 | new MovieBuilder().withId(2).withTitle('Popular Movie 2').build(), 42 | ] 43 | mockGetPopularMoviesAndItsHighlightUseCase.execute.mockResolvedValue({ 44 | highlightedMovie, 45 | popularMovies, 46 | }) 47 | 48 | render() 49 | 50 | expect(await screen.findByText(highlightedMovie.title)).toBeInTheDocument() 51 | for (const movie of popularMovies) { 52 | expect(await screen.findByText(movie.title)).toBeInTheDocument() 53 | } 54 | }) 55 | }) 56 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "movies-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "build": "next build", 7 | "dev": "next dev", 8 | "lint": "next lint && yarn lint:css && yarn lint:prettier", 9 | "lint:css": "stylelint --fix '**/*.scss' --allow-empty-input", 10 | "lint:prettier": "prettier --write . --loglevel silent", 11 | "pre-push": "node scripts/pre-push.js", 12 | "prepare": "husky install", 13 | "start": "next start", 14 | "test": "yarn test:unit:watch && yarn test:integration:watch", 15 | "test:ci": "yarn test:unit && yarn test:integration", 16 | "test:unit": "TZ=UTC jest -c jest.config.unit.js --ci", 17 | "test:unit:watch": "yarn test:unit --watch", 18 | "test:integration": "TZ=UTC jest -c jest.config.integration.js --ci", 19 | "test:integration:watch": "yarn test:integration --watch", 20 | "type-check": "yarn tsc --noEmit", 21 | "type-check:watch": "yarn type-check --incremental --watch" 22 | }, 23 | "dependencies": { 24 | "@types/node": "20.10.0", 25 | "@types/react": "18.2.39", 26 | "@types/react-dom": "18.2.17", 27 | "awilix": "9.0.0", 28 | "axios": "1.6.2", 29 | "eslint": "8.54.0", 30 | "lodash": "4.17.21", 31 | "next": "14.0.3", 32 | "react": "18.2.0", 33 | "react-dom": "18.2.0", 34 | "typescript": "5.3.2" 35 | }, 36 | "devDependencies": { 37 | "@commitlint/cli": "18.4.3", 38 | "@commitlint/config-conventional": "18.4.3", 39 | "@testing-library/jest-dom": "6.1.4", 40 | "@testing-library/react": "14.1.2", 41 | "@testing-library/user-event": "14.5.1", 42 | "@types/jest": "29.5.10", 43 | "@types/lodash": "4.14.202", 44 | "@typescript-eslint/eslint-plugin": "6.13.0", 45 | "colors": "1.4.0", 46 | "cross-spawn": "7.0.3", 47 | "eslint": "8.54.0", 48 | "eslint-config-next": "14.0.3", 49 | "eslint-config-prettier": "9.0.0", 50 | "husky": "8.0.3", 51 | "jest": "29.7.0", 52 | "jest-environment-jsdom": "29.7.0", 53 | "jest-mock-extended": "3.0.5", 54 | "knip": "3.0.2", 55 | "lint-staged": "15.1.0", 56 | "prettier": "3.1.0", 57 | "sass": "1.69.5", 58 | "stylelint": "15.11.0", 59 | "stylelint-config-recommended-scss": "13.1.0", 60 | "stylelint-order": "6.0.3" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/ui/styles/generic/normalize.scss: -------------------------------------------------------------------------------- 1 | /* Box sizing rules */ 2 | *, 3 | *::before, 4 | *::after { 5 | box-sizing: border-box; 6 | } 7 | 8 | html { 9 | box-sizing: border-box; 10 | } 11 | 12 | *, 13 | *:before, 14 | *:after { 15 | box-sizing: inherit; 16 | } 17 | 18 | * { 19 | -webkit-font-smoothing: antialiased; 20 | -moz-osx-font-smoothing: grayscale; 21 | } 22 | 23 | /* Remove default margin */ 24 | body, 25 | h1, 26 | h2, 27 | h3, 28 | h4, 29 | p, 30 | figure, 31 | blockquote, 32 | dl, 33 | dd { 34 | margin: 0; 35 | } 36 | 37 | /* Remove list styles on ul, ol elements with a list role, which suggests default styling will be removed */ 38 | ul[role='list'], 39 | ol[role='list'] { 40 | list-style: none; 41 | } 42 | 43 | /* Set core root defaults */ 44 | html:focus-within { 45 | scroll-behavior: smooth; 46 | } 47 | 48 | /* Set core body defaults */ 49 | body { 50 | text-rendering: optimizeSpeed; 51 | line-height: 1.5; 52 | font-family: 53 | var(--font-family), 54 | Roboto, 55 | -apple-system, 56 | BlinkMacSystemFont, 57 | Segoe UI, 58 | Oxygen, 59 | Ubuntu, 60 | Cantarell, 61 | Fira Sans, 62 | Droid Sans, 63 | Helvetica Neue, 64 | sans-serif; 65 | overflow-x: clip; 66 | } 67 | 68 | /* A elements that don't have a class get default styles */ 69 | a:not([class]) { 70 | text-decoration-skip-ink: auto; 71 | text-decoration: none; 72 | } 73 | 74 | /* Make images easier to work with */ 75 | img, 76 | picture { 77 | display: block; 78 | 79 | max-width: 100%; 80 | } 81 | 82 | /* Inherit fonts for inputs */ 83 | input, 84 | textarea, 85 | select { 86 | font: inherit; 87 | } 88 | 89 | button { 90 | border: unset; 91 | padding: unset; 92 | 93 | background: unset; 94 | 95 | font: inherit; 96 | text-align: unset; 97 | } 98 | 99 | /* Remove all animations and transitions for people that prefer not to see them */ 100 | @media (prefers-reduced-motion: reduce) { 101 | html:focus-within { 102 | scroll-behavior: auto; 103 | } 104 | *, 105 | *::before, 106 | *::after { 107 | animation-duration: 0.01ms !important; 108 | animation-iteration-count: 1 !important; 109 | transition-duration: 0.01ms !important; 110 | scroll-behavior: auto !important; 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/ui/styles/utilities/margins.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/mediaQueries'; 2 | 3 | $directions: ( 4 | 't': 'top', 5 | 'b': 'bottom', 6 | 'l': 'left', 7 | 'r': 'right', 8 | ); 9 | 10 | $sizes: ( 11 | 'zero': 0, 12 | 'xxs': var(--space-xxs), 13 | 'xs': var(--space-xs), 14 | 's': var(--space-s), 15 | 'm': var(--space-m), 16 | 'l': var(--space-l), 17 | 'xl': var(--space-xl), 18 | '2xl': var(--space-2xl), 19 | '3xl': var(--space-3xl), 20 | '4xl': var(--space-4xl), 21 | '5xl': var(--space-5xl), 22 | 'auto': auto, 23 | ); 24 | 25 | $devices: ( 26 | 'mobile': $onlyMobile, 27 | 'tablet': $onlyTablet, 28 | 'laptop': $onlyLaptop, 29 | 'desktop': $onlyDesktop, 30 | ); 31 | 32 | @each $nameSize, $size in $sizes { 33 | @each $nameDir, $dir in $directions { 34 | @each $device, $deviceMediaQuery in $devices { 35 | .m#{$nameDir}-#{$device}-#{$nameSize} { 36 | @media #{$deviceMediaQuery} { 37 | @if $dir == 'left' { 38 | [dir='rtl'] & { 39 | margin-right: $size !important; 40 | } 41 | :not([dir='rtl']) & { 42 | margin-left: $size !important; 43 | } 44 | } 45 | 46 | @if $dir == 'right' { 47 | [dir='rtl'] & { 48 | margin-left: $size !important; 49 | } 50 | :not([dir='rtl']) & { 51 | margin-right: $size !important; 52 | } 53 | } 54 | 55 | @if $dir == 'top' { 56 | margin-top: $size !important; 57 | } 58 | 59 | @if $dir == 'bottom' { 60 | margin-bottom: $size !important; 61 | } 62 | } 63 | } 64 | } 65 | } 66 | } 67 | 68 | @each $nameSize, $size in $sizes { 69 | @each $nameDir, $dir in $directions { 70 | .m#{$nameDir}-#{$nameSize} { 71 | @if $dir == 'left' { 72 | [dir='rtl'] & { 73 | margin-right: $size; 74 | } 75 | :not([dir='rtl']) & { 76 | margin-left: $size; 77 | } 78 | } 79 | 80 | @if $dir == 'right' { 81 | [dir='rtl'] & { 82 | margin-left: $size; 83 | } 84 | :not([dir='rtl']) & { 85 | margin-right: $size; 86 | } 87 | } 88 | 89 | @if $dir == 'top' { 90 | margin-top: $size; 91 | } 92 | 93 | @if $dir == 'bottom' { 94 | margin-bottom: $size; 95 | } 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/core/Shared/infrastructure/ApiClient.ts: -------------------------------------------------------------------------------- 1 | import { AxiosInstance, AxiosRequestConfig } from 'axios' 2 | import { isEmpty } from '@/core/Shared/infrastructure/wrappers/isEmtpy' 3 | import { isUndefined } from '@/core/Shared/infrastructure/wrappers/isUndefined' 4 | import { isDefined } from '@/core/Shared/infrastructure/wrappers/isDefined' 5 | 6 | export class ApiClient { 7 | headers = {} 8 | axiosInstance: AxiosInstance 9 | 10 | constructor(axiosInstance: AxiosInstance) { 11 | this.axiosInstance = axiosInstance 12 | } 13 | 14 | private mergeConfigAndHeaders(config?: AxiosRequestConfig) { 15 | if (isUndefined(config) && isEmpty(this.headers)) { 16 | return undefined 17 | } 18 | 19 | if (isDefined(config) && isEmpty(this.headers)) { 20 | return config 21 | } 22 | 23 | if (isUndefined(config) && !isEmpty(this.headers)) { 24 | return { 25 | headers: this.headers, 26 | } 27 | } 28 | 29 | return { 30 | ...config, 31 | headers: { 32 | ...this.headers, 33 | ...config?.headers, 34 | }, 35 | } 36 | } 37 | 38 | async get

( 39 | path: string, 40 | config?: AxiosRequestConfig, 41 | ): Promise

{ 42 | return this.axiosInstance 43 | .get

(path, this.mergeConfigAndHeaders(config)) 44 | .then

(response => ({ 45 | ...response, 46 | ...response.data, 47 | })) 48 | } 49 | 50 | async post

( 51 | path: string, 52 | data?: object, 53 | config?: AxiosRequestConfig, 54 | ): Promise

{ 55 | return this.axiosInstance 56 | .post

(path, JSON.stringify(data), this.mergeConfigAndHeaders(config)) 57 | .then

(response => ({ 58 | ...response, 59 | ...response.data, 60 | })) 61 | } 62 | 63 | async put

( 64 | path: string, 65 | data?: object, 66 | config?: AxiosRequestConfig, 67 | ): Promise

{ 68 | return this.axiosInstance 69 | .put

(path, JSON.stringify(data), this.mergeConfigAndHeaders(config)) 70 | .then

(response => ({ 71 | ...response, 72 | ...response.data, 73 | })) 74 | } 75 | 76 | async patch

( 77 | path: string, 78 | data?: object, 79 | config?: AxiosRequestConfig, 80 | ): Promise

{ 81 | return this.axiosInstance 82 | .patch

(path, JSON.stringify(data), this.mergeConfigAndHeaders(config)) 83 | .then

(response => ({ 84 | ...response, 85 | ...response.data, 86 | })) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /src/ui/views/PopularMovies/PopularMovies.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react' 2 | import { Movie } from '@/core/Movie/domain/Movie' 3 | import { Text } from '@/ui/components/atoms/Text' 4 | import { Image } from '@/ui/components/atoms/Image' 5 | import styles from './PopularMovies.module.scss' 6 | import { ImageSize } from '@/core/Movie/infrastructure/ImageSize' 7 | import { inject } from '@/_di/container' 8 | 9 | export const PopularMovies = () => { 10 | const [popularMovies, setPopularMovies] = useState([]) 11 | const [highlightedMovie, setHighlightedMovie] = useState() 12 | 13 | useEffect(() => { 14 | const fetchMovies = async () => { 15 | const { popularMovies, highlightedMovie } = await inject( 16 | 'getPopularMoviesAndItsHighlightUseCase', 17 | ).execute() 18 | 19 | setPopularMovies(popularMovies) 20 | setHighlightedMovie(highlightedMovie) 21 | } 22 | 23 | fetchMovies() 24 | }) 25 | 26 | return ( 27 |

28 | {highlightedMovie && ( 29 | <> 30 | 31 | 41 |
42 | 47 | {highlightedMovie.title} 48 | 49 | 55 | {highlightedMovie.description} 56 | 57 |
58 |
59 | 60 | )} 61 | 67 | Popular Movies 68 | 69 |
70 | {popularMovies.map((movie, index) => ( 71 | 72 | ))} 73 |
74 |
75 | ) 76 | } 77 | 78 | interface MovieProps { 79 | movie: Movie 80 | isFirstSeen?: boolean 81 | } 82 | 83 | const MovieCard: FC = ({ movie, isFirstSeen }) => { 84 | return ( 85 |
86 | 95 | 96 | {movie.title} 97 | 98 |
99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-recommended-scss", 3 | "plugins": ["stylelint-order"], 4 | "rules": { 5 | "scss/operator-no-newline-after": null, 6 | "selector-pseudo-class-no-unknown": [ 7 | true, 8 | { 9 | "ignorePseudoClasses": ["global"] 10 | } 11 | ], 12 | "order/properties-order": [ 13 | [ 14 | { 15 | "groupName": "position", 16 | "emptyLineBefore": "always", 17 | "noEmptyLineBetween": true, 18 | "properties": [ 19 | "content", 20 | "position", 21 | "z-index", 22 | "top", 23 | "right", 24 | "bottom", 25 | "left" 26 | ] 27 | }, 28 | { 29 | "groupName": "display", 30 | "emptyLineBefore": "always", 31 | "noEmptyLineBetween": true, 32 | "properties": [ 33 | "display", 34 | "gap", 35 | "place-items", 36 | "flex-direction", 37 | "flex-wrap", 38 | "flex-flow", 39 | "justify-content", 40 | "justify-items", 41 | "align-items", 42 | "align-content", 43 | "order", 44 | "flex-grow", 45 | "flex-shrink", 46 | "flex-basis", 47 | "flex", 48 | "align-self", 49 | "grid-area", 50 | "grid-auto-columns", 51 | "grid-auto-flow", 52 | "grid-auto-rows", 53 | "grid-column", 54 | "grid-column-end", 55 | "grid-column-start", 56 | "grid-row", 57 | "grid-row-end", 58 | "grid-row-start", 59 | "grid-template", 60 | "grid-template-areas", 61 | "grid-template-columns", 62 | "grid-template-rows,", 63 | "grid-template-rows", 64 | "grid-column-gap", 65 | "grid-row-gap", 66 | "column-gap", 67 | "row-gap", 68 | "opacity", 69 | "overflow", 70 | "transform" 71 | ] 72 | }, 73 | { 74 | "groupName": "box-model", 75 | "emptyLineBefore": "always", 76 | "noEmptyLineBetween": true, 77 | "properties": [ 78 | "margin", 79 | "margin-top", 80 | "margin-bottom", 81 | "margin-left", 82 | "margin-right", 83 | "box-shadow", 84 | "border", 85 | "border-bottom", 86 | "border-top", 87 | "border-left", 88 | "border-right", 89 | "border-top-left-radius", 90 | "border-bottom-left-radius", 91 | "border-top-right-radius", 92 | "border-bottom-right-radius", 93 | "border-radius", 94 | "border-color", 95 | "box-sizing", 96 | "width", 97 | "max-width", 98 | "min-width", 99 | "height", 100 | "max-height", 101 | "min-height", 102 | "padding", 103 | "padding-top", 104 | "padding-bottom", 105 | "padding-left", 106 | "padding-right" 107 | ] 108 | } 109 | ], 110 | { 111 | "unspecified": "bottom", 112 | "emptyLineBeforeUnspecified": "always" 113 | } 114 | ] 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/ui/styles/tools/mixins/fonts.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/functions'; 2 | 3 | $font-size-xs: #{toRem(12)}; 4 | $font-size-s: #{toRem(14)}; 5 | $font-size-m: #{toRem(16)}; 6 | $font-size-l: #{toRem(20)}; 7 | $font-size-xl: #{toRem(24)}; 8 | $font-size-2xl: #{toRem(28)}; 9 | $font-size-3xl: #{toRem(36)}; 10 | $font-size-4xl: #{toRem(48)}; 11 | $font-size-5xl: #{toRem(60)}; 12 | 13 | $line-height-xs: #{toRem(16)}; 14 | $line-height-s: #{toRem(20)}; 15 | $line-height-m: #{toRem(24)}; 16 | $line-height-l: #{toRem(30)}; 17 | $line-height-xl: #{toRem(34)}; 18 | $line-height-2xl: #{toRem(36)}; 19 | $line-height-3xl: #{toRem(42)}; 20 | $line-height-4xl: #{toRem(56)}; 21 | $line-height-5xl: #{toRem(70)}; 22 | 23 | $font-weight-300: 300; 24 | $font-weight-500: 500; 25 | $font-weight-700: 700; 26 | 27 | $letter-spacing-01: 0em; 28 | $letter-spacing-02: 0.0015em; 29 | $letter-spacing-03: 0.0025em; 30 | $letter-spacing-04: 0.004em; 31 | $letter-spacing-05: 0.005em; 32 | $letter-spacing-06: 0.015em; 33 | 34 | /** XS **/ 35 | 36 | @mixin font-xs-300 { 37 | font-size: #{$font-size-xs}; 38 | line-height: #{$line-height-xs}; 39 | font-weight: #{$font-weight-300}; 40 | letter-spacing: #{$letter-spacing-04}; 41 | } 42 | 43 | @mixin font-xs-500 { 44 | font-size: #{$font-size-xs}; 45 | line-height: #{$line-height-xs}; 46 | font-weight: #{$font-weight-500}; 47 | letter-spacing: #{$letter-spacing-06}; 48 | } 49 | 50 | @mixin font-xs-700 { 51 | font-size: #{$font-size-xs}; 52 | line-height: #{$line-height-xs}; 53 | font-weight: #{$font-weight-700}; 54 | letter-spacing: #{$letter-spacing-06}; 55 | } 56 | 57 | /** S **/ 58 | 59 | @mixin font-s-300 { 60 | font-size: #{$font-size-s}; 61 | line-height: #{$line-height-s}; 62 | font-weight: #{$font-weight-300}; 63 | letter-spacing: #{$letter-spacing-03}; 64 | } 65 | 66 | @mixin font-s-500 { 67 | font-size: #{$font-size-s}; 68 | line-height: #{$line-height-s}; 69 | font-weight: #{$font-weight-500}; 70 | letter-spacing: #{$letter-spacing-03}; 71 | } 72 | 73 | @mixin font-s-700 { 74 | font-size: #{$font-size-s}; 75 | line-height: #{$line-height-s}; 76 | font-weight: #{$font-weight-700}; 77 | letter-spacing: #{$letter-spacing-03}; 78 | } 79 | 80 | @mixin font-s-link { 81 | font-size: #{$font-size-s}; 82 | font-weight: #{$font-weight-500}; 83 | letter-spacing: #{$letter-spacing-03}; 84 | } 85 | 86 | /** M **/ 87 | 88 | @mixin font-m-300 { 89 | font-size: #{$font-size-m}; 90 | line-height: #{$line-height-m}; 91 | font-weight: #{$font-weight-300}; 92 | letter-spacing: #{$letter-spacing-05}; 93 | } 94 | 95 | @mixin font-m-500 { 96 | font-size: #{$font-size-m}; 97 | line-height: #{$line-height-m}; 98 | font-weight: #{$font-weight-500}; 99 | letter-spacing: #{$letter-spacing-05}; 100 | } 101 | 102 | @mixin font-m-700 { 103 | font-size: #{$font-size-m}; 104 | line-height: #{$line-height-m}; 105 | font-weight: #{$font-weight-700}; 106 | letter-spacing: #{$letter-spacing-05}; 107 | } 108 | 109 | @mixin font-link { 110 | font-size: #{$font-size-m}; 111 | line-height: #{$line-height-m}; 112 | font-weight: #{$font-weight-500}; 113 | letter-spacing: #{$letter-spacing-05}; 114 | } 115 | 116 | @mixin font-button { 117 | font-size: #{$font-size-m}; 118 | line-height: #{$line-height-l}; 119 | font-weight: #{$font-weight-700}; 120 | letter-spacing: #{$letter-spacing-05}; 121 | } 122 | 123 | /** L **/ 124 | 125 | @mixin font-l-300 { 126 | font-size: #{$font-size-l}; 127 | line-height: #{$line-height-l}; 128 | font-weight: #{$font-weight-300}; 129 | letter-spacing: #{$letter-spacing-02}; 130 | } 131 | 132 | @mixin font-l-700 { 133 | font-size: #{$font-size-l}; 134 | line-height: #{$line-height-l}; 135 | font-weight: #{$font-weight-700}; 136 | letter-spacing: #{$letter-spacing-02}; 137 | } 138 | 139 | /** XL **/ 140 | 141 | @mixin font-xl-300 { 142 | font-size: #{$font-size-xl}; 143 | line-height: #{$line-height-xl}; 144 | font-weight: #{$font-weight-300}; 145 | letter-spacing: #{$letter-spacing-01}; 146 | } 147 | 148 | @mixin font-xl-700 { 149 | font-size: #{$font-size-xl}; 150 | line-height: #{$line-height-xl}; 151 | font-weight: #{$font-weight-700}; 152 | letter-spacing: #{$letter-spacing-01}; 153 | } 154 | 155 | /** 2XL **/ 156 | 157 | @mixin font-2xl-700 { 158 | font-size: #{$font-size-2xl}; 159 | line-height: #{$line-height-2xl}; 160 | font-weight: #{$font-weight-700}; 161 | letter-spacing: #{$letter-spacing-02}; 162 | } 163 | 164 | /** 3XL **/ 165 | 166 | @mixin font-3xl-700 { 167 | font-size: #{$font-size-3xl}; 168 | line-height: #{$line-height-3xl}; 169 | font-weight: #{$font-weight-700}; 170 | letter-spacing: #{$letter-spacing-03}; 171 | } 172 | 173 | /** 4XL **/ 174 | 175 | @mixin font-4xl-500 { 176 | font-size: #{$font-size-4xl}; 177 | line-height: #{$line-height-4xl}; 178 | font-weight: #{$font-weight-500}; 179 | letter-spacing: #{$letter-spacing-01}; 180 | } 181 | -------------------------------------------------------------------------------- /src/ui/components/atoms/Text/Text.module.scss: -------------------------------------------------------------------------------- 1 | @import 'src/ui/styles/tools/mixins/fonts'; 2 | @import 'src/ui/styles/tools/mediaQueries'; 3 | 4 | .mobile-xs-300 { 5 | @include font-xs-300; 6 | } 7 | 8 | .mobile-xs-500 { 9 | @include font-xs-500; 10 | } 11 | 12 | .mobile-xs-700 { 13 | @include font-xs-700; 14 | } 15 | 16 | .mobile-s-300 { 17 | @include font-s-300; 18 | } 19 | 20 | .mobile-s-500 { 21 | @include font-s-500; 22 | } 23 | 24 | .mobile-s-700 { 25 | @include font-s-700; 26 | } 27 | 28 | .mobile-m-300 { 29 | @include font-m-300; 30 | } 31 | 32 | .mobile-m-500 { 33 | @include font-m-500; 34 | } 35 | 36 | .mobile-m-700 { 37 | @include font-m-700; 38 | } 39 | 40 | .mobile-link { 41 | @include font-link; 42 | } 43 | 44 | .mobile-button { 45 | @include font-button; 46 | } 47 | 48 | .mobile-l-300 { 49 | @include font-l-300; 50 | } 51 | 52 | .mobile-l-700 { 53 | @include font-l-700; 54 | } 55 | 56 | .mobile-xl-300 { 57 | @include font-xl-300; 58 | } 59 | 60 | .mobile-xl-700 { 61 | @include font-xl-700; 62 | } 63 | 64 | .mobile-2xl-700 { 65 | @include font-2xl-700; 66 | } 67 | 68 | .mobile-3xl-700 { 69 | @include font-3xl-700; 70 | } 71 | 72 | .mobile-4xl-500 { 73 | @include font-4xl-500; 74 | } 75 | 76 | @media #{$fromTablet} { 77 | .tablet-xs-300 { 78 | @include font-xs-300; 79 | } 80 | 81 | .tablet-xs-500 { 82 | @include font-xs-500; 83 | } 84 | 85 | .tablet-xs-700 { 86 | @include font-xs-700; 87 | } 88 | 89 | .tablet-s-300 { 90 | @include font-s-300; 91 | } 92 | 93 | .tablet-s-500 { 94 | @include font-s-500; 95 | } 96 | 97 | .tablet-s-700 { 98 | @include font-m-700; 99 | } 100 | 101 | .tablet-link { 102 | @include font-link; 103 | } 104 | 105 | .tablet-button { 106 | @include font-button; 107 | } 108 | 109 | .tablet-m-300 { 110 | @include font-m-300; 111 | } 112 | 113 | .tablet-m-500 { 114 | @include font-m-500; 115 | } 116 | 117 | .tablet-m-700 { 118 | @include font-m-700; 119 | } 120 | 121 | .tablet-l-300 { 122 | @include font-l-300; 123 | } 124 | 125 | .tablet-l-700 { 126 | @include font-l-700; 127 | } 128 | 129 | .tablet-xl-300 { 130 | @include font-xl-300; 131 | } 132 | 133 | .tablet-xl-700 { 134 | @include font-xl-700; 135 | } 136 | 137 | .tablet-2xl-700 { 138 | @include font-2xl-700; 139 | } 140 | 141 | .tablet-3xl-700 { 142 | @include font-3xl-700; 143 | } 144 | 145 | .tablet-4xl-500 { 146 | @include font-4xl-500; 147 | } 148 | } 149 | 150 | @media #{$fromLaptop} { 151 | .laptop-xs-300 { 152 | @include font-xs-300; 153 | } 154 | 155 | .laptop-xs-500 { 156 | @include font-xs-500; 157 | } 158 | 159 | .laptop-xs-700 { 160 | @include font-xs-700; 161 | } 162 | 163 | .laptop-s-300 { 164 | @include font-s-300; 165 | } 166 | 167 | .laptop-s-500 { 168 | @include font-s-500; 169 | } 170 | 171 | .laptop-s-700 { 172 | @include font-s-700; 173 | } 174 | 175 | .laptop-link { 176 | @include font-link; 177 | } 178 | 179 | .laptop-button { 180 | @include font-button; 181 | } 182 | 183 | .laptop-m-300 { 184 | @include font-m-300; 185 | } 186 | 187 | .laptop-m-500 { 188 | @include font-m-500; 189 | } 190 | 191 | .laptop-m-700 { 192 | @include font-m-700; 193 | } 194 | 195 | .laptop-l-300 { 196 | @include font-l-300; 197 | } 198 | 199 | .laptop-l-700 { 200 | @include font-l-700; 201 | } 202 | 203 | .laptop-xl-300 { 204 | @include font-xl-300; 205 | } 206 | 207 | .laptop-xl-700 { 208 | @include font-xl-700; 209 | } 210 | 211 | .laptop-2xl-700 { 212 | @include font-2xl-700; 213 | } 214 | 215 | .laptop-3xl-700 { 216 | @include font-3xl-700; 217 | } 218 | 219 | .laptop-4xl-500 { 220 | @include font-4xl-500; 221 | } 222 | } 223 | 224 | @media #{$onlyDesktop} { 225 | .desktop-xs-300 { 226 | @include font-xs-300; 227 | } 228 | 229 | .desktop-xs-500 { 230 | @include font-xs-500; 231 | } 232 | 233 | .desktop-xs-700 { 234 | @include font-xs-700; 235 | } 236 | 237 | .desktop-s-300 { 238 | @include font-s-300; 239 | } 240 | 241 | .desktop-s-500 { 242 | @include font-s-500; 243 | } 244 | 245 | .desktop-s-700 { 246 | @include font-s-700; 247 | } 248 | 249 | .desktop-link { 250 | @include font-link; 251 | } 252 | 253 | .desktop-button { 254 | @include font-button; 255 | } 256 | 257 | .desktop-m-300 { 258 | @include font-m-300; 259 | } 260 | 261 | .desktop-m-500 { 262 | @include font-m-500; 263 | } 264 | 265 | .desktop-m-700 { 266 | @include font-m-700; 267 | } 268 | 269 | .desktop-l-300 { 270 | @include font-l-300; 271 | } 272 | 273 | .desktop-l-700 { 274 | @include font-l-700; 275 | } 276 | 277 | .desktop-xl-300 { 278 | @include font-xl-300; 279 | } 280 | 281 | .desktop-xl-700 { 282 | @include font-xl-700; 283 | } 284 | 285 | .desktop-2xl-700 { 286 | @include font-2xl-700; 287 | } 288 | 289 | .desktop-3xl-700 { 290 | @include font-3xl-700; 291 | } 292 | 293 | .desktop-4xl-500 { 294 | @include font-4xl-500; 295 | } 296 | } 297 | 298 | /** COLORS **/ 299 | .color-light { 300 | color: var(--color-text-light); 301 | } 302 | 303 | .color-mid { 304 | color: var(--color-text-mid); 305 | } 306 | 307 | .color-primary { 308 | color: var(--color-text-primary); 309 | } 310 | 311 | .color-dark { 312 | color: var(--color-text-dark); 313 | } 314 | 315 | .color-fill-01 { 316 | color: var(--color-fill-neutral-03); 317 | } 318 | 319 | .color-disabled { 320 | color: var(--color-text-disabled); 321 | -webkit-text-fill-color: var(--color-text-disabled); 322 | } 323 | 324 | .color-support-success { 325 | color: var(--color-support-success); 326 | } 327 | 328 | .color-support-error { 329 | color: var(--color-support-error); 330 | } 331 | 332 | .toUppercase { 333 | text-transform: uppercase; 334 | } 335 | 336 | .centered { 337 | text-align: center; 338 | } 339 | -------------------------------------------------------------------------------- /src/core/Shared/infrastructure/__tests__/ApiClient.test.ts: -------------------------------------------------------------------------------- 1 | import { mock, MockProxy } from 'jest-mock-extended' 2 | import { AxiosInstance, AxiosRequestConfig } from 'axios' 3 | import { ApiClient } from '@/core/Shared/infrastructure/ApiClient' 4 | 5 | describe('Cliente API base', () => { 6 | let basicApiClient: ApiClient 7 | let mockAxiosInstance: MockProxy 8 | 9 | beforeEach(() => { 10 | mockAxiosInstance = mock() 11 | basicApiClient = new ApiClient(mockAxiosInstance) 12 | }) 13 | 14 | it('llama a GET sin configuración adicional', async () => { 15 | const response = { 16 | data: { 17 | response: 'response', 18 | }, 19 | } 20 | mockAxiosInstance.get.mockResolvedValue(response) 21 | 22 | const result = await basicApiClient.get('/test') 23 | 24 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/test', undefined) 25 | expect(result).toEqual({ 26 | data: { 27 | response: 'response', 28 | }, 29 | response: 'response', 30 | }) 31 | }) 32 | 33 | it('llama a GET con headers', async () => { 34 | mockAxiosInstance.get.mockResolvedValue({ data: undefined }) 35 | const headers = { 36 | headers: { 37 | header: 'header', 38 | }, 39 | } 40 | 41 | await basicApiClient.get('/test', headers) 42 | 43 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/test', headers) 44 | }) 45 | 46 | it.each<['post' | 'patch' | 'put']>([['post'], ['patch'], ['put']])( 47 | 'llama a %s sin configuración adicional', 48 | async method => { 49 | const response = { 50 | data: { 51 | response: 'response', 52 | }, 53 | } 54 | mockAxiosInstance[method].mockResolvedValue(response) 55 | 56 | const result = await basicApiClient[method]('/test') 57 | 58 | expect(mockAxiosInstance[method]).toHaveBeenCalledWith( 59 | '/test', 60 | undefined, 61 | undefined, 62 | ) 63 | expect(result).toEqual({ 64 | data: { 65 | response: 'response', 66 | }, 67 | response: 'response', 68 | }) 69 | }, 70 | ) 71 | 72 | it.each<['post' | 'patch' | 'put']>([['post'], ['patch'], ['put']])( 73 | 'llama a %s con datos', 74 | async method => { 75 | mockAxiosInstance[method].mockResolvedValue({ data: undefined }) 76 | const data = { data: 'data' } 77 | 78 | await basicApiClient[method]('/test', data) 79 | 80 | expect(mockAxiosInstance[method]).toHaveBeenCalledWith( 81 | '/test', 82 | '{"data":"data"}', 83 | undefined, 84 | ) 85 | }, 86 | ) 87 | 88 | it.each<['post' | 'patch' | 'put']>([['post'], ['patch'], ['put']])( 89 | 'llama a %s con headers', 90 | async method => { 91 | mockAxiosInstance[method].mockResolvedValue({ data: undefined }) 92 | const headers = { 93 | headers: { 94 | header: 'header', 95 | }, 96 | } 97 | 98 | await basicApiClient[method]('/test', undefined, headers) 99 | 100 | expect(mockAxiosInstance[method]).toHaveBeenCalledWith( 101 | '/test', 102 | undefined, 103 | headers, 104 | ) 105 | }, 106 | ) 107 | 108 | it('manda los headers configurados en cliente custom', async () => { 109 | mockAxiosInstance.get.mockResolvedValue({ data: undefined }) 110 | const testApiClient = new TestApiClient(mockAxiosInstance) 111 | 112 | await testApiClient.addHeaders().get('/url') 113 | 114 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/url', { 115 | headers: { 116 | test: 'test', 117 | }, 118 | }) 119 | }) 120 | 121 | it('manda los headers por llamada sin configurar ninguno en cliente custom', async () => { 122 | mockAxiosInstance.get.mockResolvedValue({ data: undefined }) 123 | const testApiClient = new TestApiClient(mockAxiosInstance) 124 | 125 | await testApiClient.get('/url', { 126 | headers: { 127 | onlyThisUrl: 'onlyThisUrl', 128 | }, 129 | }) 130 | 131 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/url', { 132 | headers: { 133 | onlyThisUrl: 'onlyThisUrl', 134 | }, 135 | }) 136 | }) 137 | 138 | it('mezcla los headers por llamada con headers configurados en clientes custom', async () => { 139 | mockAxiosInstance.get.mockResolvedValue({ data: undefined }) 140 | const testApiClient = new TestApiClient(mockAxiosInstance) 141 | 142 | await testApiClient.addHeaders().get('/url', { 143 | headers: { 144 | onlyThisUrl: 'onlyThisUrl', 145 | }, 146 | }) 147 | 148 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/url', { 149 | headers: { 150 | test: 'test', 151 | onlyThisUrl: 'onlyThisUrl', 152 | }, 153 | }) 154 | }) 155 | 156 | it('no manda headers en clientes custom', async () => { 157 | mockAxiosInstance.get.mockResolvedValue({ data: undefined }) 158 | const testApiClient = new TestApiClient(mockAxiosInstance) 159 | 160 | await testApiClient.get('/url') 161 | 162 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/url', undefined) 163 | }) 164 | 165 | it('manda headers por llamada cuando coinciden con configurados en clientes custom', async () => { 166 | mockAxiosInstance.get.mockResolvedValue({ data: undefined }) 167 | const testApiClient = new TestApiClient(mockAxiosInstance) 168 | 169 | await testApiClient.addHeaders().get('/url', { 170 | headers: { 171 | test: 'onlyThisUrl', 172 | }, 173 | }) 174 | 175 | expect(mockAxiosInstance.get).toHaveBeenCalledWith('/url', { 176 | headers: { 177 | test: 'onlyThisUrl', 178 | }, 179 | }) 180 | }) 181 | }) 182 | 183 | class TestApiClient extends ApiClient { 184 | addHeaders() { 185 | this.headers = { 186 | test: 'test', 187 | } 188 | return this 189 | } 190 | 191 | async get

( 192 | path: string, 193 | config?: AxiosRequestConfig, 194 | ): Promise

{ 195 | return super.get(path, config) 196 | } 197 | } 198 | --------------------------------------------------------------------------------