├── .all-contributorsrc ├── .browserslistrc ├── .commitlintrc.json ├── .editorconfig ├── .github └── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── .gitignore ├── .husky ├── commit-msg ├── pre-commit └── pre-push ├── .npmrc ├── .prettierignore ├── .prettierrc ├── .stylelintignore ├── .stylelintrc.json ├── CHANGELOG.md ├── README.md ├── angular.json ├── commitlint.config.js ├── e2e └── home.spec.ts ├── eslint.config.mjs ├── karma.conf.js ├── package-lock.json ├── package.json ├── playwright.config.ts ├── public ├── _redirects └── robots.txt ├── src ├── app │ ├── app.component.html │ ├── app.component.scss │ ├── app.component.spec.ts │ ├── app.component.ts │ ├── app.config.ts │ ├── app.routes.ts │ ├── core │ │ ├── components │ │ │ ├── card │ │ │ │ ├── card.component.html │ │ │ │ ├── card.component.scss │ │ │ │ └── card.component.ts │ │ │ ├── cookie-popup │ │ │ │ ├── cookie-popup.component.html │ │ │ │ ├── cookie-popup.component.scss │ │ │ │ └── cookie-popup.component.ts │ │ │ ├── decorative-header │ │ │ │ ├── decorative-header.component.html │ │ │ │ ├── decorative-header.component.scss │ │ │ │ └── decorative-header.component.ts │ │ │ ├── error-404 │ │ │ │ ├── error-404.component.html │ │ │ │ ├── error-404.component.scss │ │ │ │ └── error-404.component.ts │ │ │ ├── footer │ │ │ │ ├── footer.component.html │ │ │ │ ├── footer.component.scss │ │ │ │ └── footer.component.ts │ │ │ ├── header │ │ │ │ ├── header.component.html │ │ │ │ ├── header.component.scss │ │ │ │ └── header.component.ts │ │ │ ├── language-selector │ │ │ │ ├── language-selector.component.html │ │ │ │ └── language-selector.component.ts │ │ │ ├── progress-bar │ │ │ │ └── progress-bar.component.ts │ │ │ ├── theme-button │ │ │ │ ├── theme-button.component.html │ │ │ │ └── theme-button.component.ts │ │ │ └── toast-stack │ │ │ │ ├── toast-stack.component.html │ │ │ │ └── toast-stack.component.ts │ │ ├── constants │ │ │ ├── alerts.constants.ts │ │ │ ├── api-error-codes.constants.ts │ │ │ ├── endpoints.constants.ts │ │ │ ├── language.constants.ts │ │ │ ├── paths.constants.ts │ │ │ └── urls.constants.ts │ │ ├── directives │ │ │ ├── lowercase.directive.ts │ │ │ ├── sl-checkbox-control.directive.ts │ │ │ ├── sl-input-icon-focus.directive.ts │ │ │ ├── sl-select-control.directive.ts │ │ │ └── trim.directive.ts │ │ ├── enums │ │ │ ├── app-error.enum.ts │ │ │ ├── language.enum.ts │ │ │ └── locale.enum.ts │ │ ├── guards │ │ │ ├── authentication.guard.ts │ │ │ └── no-authentication.guard.ts │ │ ├── interceptors │ │ │ ├── authentication.interceptor.ts │ │ │ └── caching.interceptor.ts │ │ ├── pipes │ │ │ └── first-title-case.pipe.ts │ │ ├── providers │ │ │ └── local-storage.ts │ │ ├── services │ │ │ ├── analytics.service.ts │ │ │ ├── language.service.ts │ │ │ ├── storage │ │ │ │ ├── cookie-consent.service.ts │ │ │ │ └── file.service.ts │ │ │ └── ui │ │ │ │ ├── alert.store.ts │ │ │ │ ├── header.service.ts │ │ │ │ └── theme-manager.service.ts │ │ ├── tokens │ │ │ └── environment.token.ts │ │ ├── types │ │ │ └── api-response.types.ts │ │ └── validators │ │ │ ├── email.validator.ts │ │ │ ├── password.validator.ts │ │ │ └── pokemon.validator.ts │ └── features │ │ ├── authentication │ │ ├── authentication.routes.ts │ │ ├── pages │ │ │ ├── log-in │ │ │ │ ├── log-in-form.types.ts │ │ │ │ ├── log-in.component.html │ │ │ │ ├── log-in.component.scss │ │ │ │ └── log-in.component.ts │ │ │ ├── my-account │ │ │ │ ├── my-account.component.html │ │ │ │ ├── my-account.component.scss │ │ │ │ └── my-account.component.ts │ │ │ └── register │ │ │ │ ├── register-form.types.ts │ │ │ │ ├── register.component.html │ │ │ │ ├── register.component.scss │ │ │ │ └── register.component.ts │ │ ├── services │ │ │ ├── authentication.service.ts │ │ │ └── user.service.ts │ │ └── types │ │ │ ├── catch-pokemon-request.type.ts │ │ │ ├── catch-pokemon-response.type.ts │ │ │ ├── get-me-response.type.ts │ │ │ ├── login-request.type.ts │ │ │ ├── login-response.type.ts │ │ │ ├── refresh-token.response.type.ts │ │ │ ├── register-request.type.ts │ │ │ ├── register-response.type.ts │ │ │ ├── update-user-request.type.ts │ │ │ ├── update-user-response.type.ts │ │ │ └── user.type.ts │ │ ├── home │ │ ├── home.component.html │ │ ├── home.component.scss │ │ └── home.component.ts │ │ └── pokemon │ │ ├── components │ │ ├── catch-animation │ │ │ ├── catch-animation.component.html │ │ │ ├── catch-animation.component.scss │ │ │ ├── catch-animation.component.ts │ │ │ └── catch.animations.ts │ │ ├── pokedex │ │ │ ├── enums │ │ │ │ └── pokedex-action.enum.ts │ │ │ ├── pokedex-pads.component.scss │ │ │ ├── pokedex.component.html │ │ │ ├── pokedex.component.scss │ │ │ └── pokedex.component.ts │ │ ├── pokemon-battlefield │ │ │ ├── pokemon-battlefield.component.html │ │ │ ├── pokemon-battlefield.component.scss │ │ │ └── pokemon-battlefield.component.ts │ │ ├── pokemon-card │ │ │ ├── pokemon-card.component.html │ │ │ ├── pokemon-card.component.scss │ │ │ └── pokemon-card.component.ts │ │ ├── pokemon-image │ │ │ ├── pokemon-image.component.html │ │ │ ├── pokemon-image.component.scss │ │ │ └── pokemon-image.component.ts │ │ └── pokemon-search-input │ │ │ ├── pokemon-search-input.component.html │ │ │ ├── pokemon-search-input.component.scss │ │ │ └── pokemon-search-input.component.ts │ │ ├── pages │ │ ├── my-pokemon │ │ │ ├── my-pokemon.component.html │ │ │ ├── my-pokemon.component.scss │ │ │ └── my-pokemon.component.ts │ │ └── pokemon-detail │ │ │ ├── pokemon-detail.component.html │ │ │ ├── pokemon-detail.component.scss │ │ │ └── pokemon-detail.component.ts │ │ ├── pokemon.routes.ts │ │ ├── services │ │ ├── crop-image.service.ts │ │ └── pokemon.service.ts │ │ └── types │ │ └── pokemon.type.ts ├── environments │ ├── environment.production.ts │ └── environment.ts ├── index.html ├── locale │ ├── messages.es.xlf │ ├── messages.xlf │ └── translations.ts ├── main.ts └── styles │ ├── base │ ├── _border-radius.scss │ ├── _color-definitions.scss │ ├── _media-queries.scss │ ├── _primitive-colors.scss │ ├── _reset.scss │ ├── _spacing.scss │ ├── _themes.scss │ ├── _typography.scss │ └── _z-index.scss │ ├── components │ ├── _alerts.scss │ ├── _buttons.scss │ ├── _checkboxes.scss │ ├── _dropdowns.scss │ ├── _forms.scss │ ├── _headings.scss │ ├── _inputs.scss │ ├── _kbd.scss │ ├── _links.scss │ ├── _loaders.scss │ ├── _options.scss │ ├── _pages.scss │ └── _selects.scss │ └── global.scss ├── tsconfig.app.json ├── tsconfig.eslint.json ├── tsconfig.json └── tsconfig.spec.json /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "angular-example-app", 3 | "projectOwner": "Ismaestro", 4 | "files": [ 5 | "README.md" 6 | ], 7 | "commitType": "docs", 8 | "commitConvention": "angular", 9 | "contributorsPerLine": 6, 10 | "contributors": [ 11 | { 12 | "login": "magicalyak", 13 | "name": "Tom Gamull", 14 | "avatar_url": "https://avatars.githubusercontent.com/u/6165889?v=4", 15 | "profile": "https://magicalyak.org", 16 | "contributions": [ 17 | "infra" 18 | ] 19 | }, 20 | { 21 | "login": "mansya", 22 | "name": "mansyaprime", 23 | "avatar_url": "https://avatars.githubusercontent.com/u/33461607?v=4", 24 | "profile": "https://github.com/mansya", 25 | "contributions": [ 26 | "code" 27 | ] 28 | }, 29 | { 30 | "login": "codeimmortal", 31 | "name": "codeimmortal", 32 | "avatar_url": "https://avatars.githubusercontent.com/u/16804408?v=4", 33 | "profile": "https://github.com/codeimmortal", 34 | "contributions": [ 35 | "code" 36 | ] 37 | }, 38 | { 39 | "login": "tomasfse", 40 | "name": "tomasfse", 41 | "avatar_url": "https://avatars.githubusercontent.com/u/22914697?v=4", 42 | "profile": "https://github.com/tomasfse", 43 | "contributions": [ 44 | "code" 45 | ] 46 | }, 47 | { 48 | "login": "golu7679", 49 | "name": "golu", 50 | "avatar_url": "https://avatars.githubusercontent.com/u/55990159?v=4", 51 | "profile": "https://golu7679.github.io", 52 | "contributions": [ 53 | "code" 54 | ] 55 | }, 56 | { 57 | "login": "v-rr", 58 | "name": "rancyr", 59 | "avatar_url": "https://avatars.githubusercontent.com/u/90811840?v=4", 60 | "profile": "https://github.com/microsoft/Secure-Supply-Chain/", 61 | "contributions": [ 62 | "code" 63 | ] 64 | }, 65 | { 66 | "login": "codingphasedotcom", 67 | "name": "codingphasedotcom", 68 | "avatar_url": "https://avatars.githubusercontent.com/u/26421899?v=4", 69 | "profile": "http://www.codingphase.com", 70 | "contributions": [ 71 | "code" 72 | ] 73 | }, 74 | { 75 | "login": "scip92", 76 | "name": "Max", 77 | "avatar_url": "https://avatars.githubusercontent.com/u/15237896?v=4", 78 | "profile": "https://github.com/scip92", 79 | "contributions": [ 80 | "code" 81 | ] 82 | }, 83 | { 84 | "login": "HerbertKarajan", 85 | "name": "Karajan", 86 | "avatar_url": "https://avatars.githubusercontent.com/u/20851191?v=4", 87 | "profile": "https://github.com/HerbertKarajan", 88 | "contributions": [ 89 | "code" 90 | ] 91 | }, 92 | { 93 | "login": "carlchandev", 94 | "name": "Carl Chan", 95 | "avatar_url": "https://avatars.githubusercontent.com/u/34772941?v=4", 96 | "profile": "https://github.com/carlchandev", 97 | "contributions": [ 98 | "code" 99 | ] 100 | }, 101 | { 102 | "login": "dyeimys", 103 | "name": "Dyeimys Franco Correa", 104 | "avatar_url": "https://avatars.githubusercontent.com/u/4250372?v=4", 105 | "profile": "https://github.com/dyeimys", 106 | "contributions": [ 107 | "code" 108 | ] 109 | }, 110 | { 111 | "login": "mugan86", 112 | "name": "Anartz Mugika Ledo", 113 | "avatar_url": "https://avatars.githubusercontent.com/u/5081970?v=4", 114 | "profile": "https://anartz-mugika.com/qwik-book/es/", 115 | "contributions": [ 116 | "code" 117 | ] 118 | } 119 | ] 120 | } 121 | -------------------------------------------------------------------------------- /.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is used by the build system to adjust CSS and JS output to support the specified browsers below. 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | 5 | # For the full list of supported browsers by the Angular framework, please see: 6 | # https://angular.io/guide/browser-support 7 | 8 | # You can see what browsers were selected by your queries by running: 9 | # npx browserslist 10 | 11 | # Include the most recent version of Firefox and Chrome, as well as Safari and Edge 12 | Firefox >= 78 13 | Chrome >= 90 14 | Safari >= 14 15 | Edge >= 90 16 | 17 | -------------------------------------------------------------------------------- /.commitlintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@commitlint/config-conventional"], 3 | "rules": { 4 | "type-enum": [2, "always", ["build", "docs", "feat", "fix", "perf", "refactor", "test"]], 5 | "scope-enum": [ 6 | 2, 7 | "always", 8 | ["tools", "styles", "e2e", "version", "app", "core", "auth", "home", "pokemon", "pokedex"] 9 | ], 10 | "subject-case": [2, "always", "lower-case"], 11 | "subject-full-stop": [2, "never"] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # Editor configuration, see https://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 2 8 | end_of_line = lf 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*.ts] 13 | quote_type = single 14 | 15 | [*.md] 16 | insert_final_newline = false 17 | trim_trailing_whitespace = false 18 | 19 | [*.xlf] 20 | insert_final_newline = false 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Describe the bug** A clear and concise description of what the bug is. 10 | 11 | **To Reproduce** Steps to reproduce the behavior: 12 | 13 | 1. Go to '...' 14 | 2. Click on '....' 15 | 3. Scroll down to '....' 16 | 4. See error 17 | 18 | **Expected behavior** A clear and concise description of what you expected to happen. 19 | 20 | **Screenshots** If applicable, add screenshots to help explain your problem. 21 | 22 | **Desktop (please complete the following information):** 23 | 24 | - OS: [e.g. iOS] 25 | - Browser [e.g. chrome, safari] 26 | - Version [e.g. 22] 27 | 28 | **Smartphone (please complete the following information):** 29 | 30 | - Device: [e.g. iPhone6] 31 | - OS: [e.g. iOS8.1] 32 | - Browser [e.g. stock browser, safari] 33 | - Version [e.g. 22] 34 | 35 | **Additional context** Add any other context about the problem here. 36 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | --- 8 | 9 | **Is your feature request related to a problem? Please describe.** A clear and concise description 10 | of what the problem is. Ex. I'm always frustrated when [...] 11 | 12 | **Describe the solution you'd like** A clear and concise description of what you want to happen. 13 | 14 | **Describe alternatives you've considered** A clear and concise description of any alternative 15 | solutions or features you've considered. 16 | 17 | **Additional context** Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /test-results 6 | /tmp 7 | /out-tsc 8 | /bazel-out 9 | 10 | # Node 11 | /node_modules 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # IDEs and editors 16 | .idea/ 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # Visual Studio Code 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # Miscellaneous 33 | /.angular/cache 34 | .sass-cache/ 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | testem.log 39 | /typings 40 | /playwright-report 41 | 42 | # System files 43 | .DS_Store 44 | Thumbs.db 45 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | npx commitlint --edit 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npm run lint 2 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #npm run test 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | legacy-peer-deps=true 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | node_modules 4 | .angular/cache 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 100, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "embeddedLanguageFormatting": "off", 6 | "singleQuote": true, 7 | "semi": true, 8 | "quoteProps": "preserve", 9 | "bracketSpacing": true, 10 | "trailingComma": "all", 11 | "overrides": [ 12 | { 13 | "files": ".prettierrc", 14 | "options": { "parser": "json" } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.stylelintignore: -------------------------------------------------------------------------------- 1 | # See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files. 2 | 3 | # Compiled output 4 | /dist 5 | /test-results 6 | /tmp 7 | /out-tsc 8 | /bazel-out 9 | 10 | # Node 11 | /node_modules 12 | npm-debug.log 13 | yarn-error.log 14 | 15 | # IDEs and editors 16 | .idea/ 17 | .project 18 | .classpath 19 | .c9/ 20 | *.launch 21 | .settings/ 22 | *.sublime-workspace 23 | 24 | # Visual Studio Code 25 | .vscode/* 26 | !.vscode/settings.json 27 | !.vscode/tasks.json 28 | !.vscode/launch.json 29 | !.vscode/extensions.json 30 | .history/* 31 | 32 | # Miscellaneous 33 | /.angular/cache 34 | .sass-cache/ 35 | /connect.lock 36 | /coverage 37 | /libpeerconnection.log 38 | testem.log 39 | /typings 40 | /playwright-report 41 | 42 | # System files 43 | .DS_Store 44 | Thumbs.db 45 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["stylelint-config-standard-scss", "stylelint-config-recess-order"], 3 | "customSyntax": "postcss-scss", 4 | "plugins": ["stylelint-order"], 5 | "rules": { 6 | "import-notation": null, 7 | "function-no-unknown": null, 8 | "no-descending-specificity": null, 9 | "at-rule-no-unknown": null, 10 | "selector-class-pattern": "^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*(__[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?(--[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*)?$" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # 19.0.0 (2025-01-29) 4 | 5 | ### app 6 | 7 | | Type | Description | 8 | | -- |------------------------------------------------| 9 | | feat | add number of real time users inside home page | 10 | 11 | --- 12 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | }; 4 | -------------------------------------------------------------------------------- /e2e/home.spec.ts: -------------------------------------------------------------------------------- 1 | import { test } from '@playwright/test'; 2 | 3 | test.describe('Home page', () => { 4 | test('should load default route', async ({ page }) => { 5 | await page.goto('http://localhost:4200'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | config.set({ 6 | basePath: '', 7 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 8 | plugins: [ 9 | require('karma-jasmine'), 10 | require('karma-chrome-launcher'), 11 | require('karma-jasmine-html-reporter'), 12 | require('karma-coverage'), 13 | require('@angular-devkit/build-angular/plugins/karma'), 14 | { 'reporter:jasmine-seed': ['type', JasmineSeedReporter] }, 15 | ], 16 | client: { 17 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 18 | jasmine: { 19 | // you can add configuration options for Jasmine here 20 | // the possible options are listed at https://jasmine.github.io/api/edge/Configuration.html 21 | // for example, you can disable the random execution with `random: false` 22 | // or set a specific seed with `seed: 4321` 23 | random: true, 24 | seed: '', 25 | }, 26 | }, 27 | jasmineHtmlReporter: { 28 | suppressAll: true, // removes the duplicated traces 29 | }, 30 | coverageReporter: { 31 | dir: require('path').join(__dirname, './coverage/angularexampleapp'), 32 | subdir: '.', 33 | reporters: [{ type: 'html' }, { type: 'text-summary' }], 34 | // check: { 35 | // global: { 36 | // statements: 54, 37 | // lines: 56, 38 | // branches: 52, 39 | // functions: 41, 40 | // }, 41 | // }, 42 | }, 43 | reporters: ['progress', 'kjhtml', 'jasmine-seed'], 44 | reportSlowerThan: 100, 45 | port: 9876, 46 | colors: true, 47 | logLevel: config.LOG_INFO, 48 | autoWatch: true, 49 | customLaunchers: { 50 | ChromeHeadlessNoSandbox: { 51 | base: 'ChromeHeadless', 52 | flags: [ 53 | '--no-sandbox', 54 | '--headless', 55 | '--disable-gpu', 56 | '--disable-dev-shm-usage', 57 | '--hide-scrollbars', 58 | '--mute-audio', 59 | ], 60 | }, 61 | }, 62 | browsers: ['ChromeHeadlessNoSandbox'], 63 | browserNoActivityTimeout: 60000, 64 | singleRun: false, 65 | restartOnFileChange: true, 66 | }); 67 | }; 68 | 69 | // Helpers 70 | function JasmineSeedReporter(baseReporterDecorator) { 71 | baseReporterDecorator(this); 72 | 73 | this.onBrowserComplete = (browser, result) => { 74 | const seed = result.order && result.order.random && result.order.seed; 75 | if (seed) this.write(`${browser}: Randomized with seed ${seed}.\n`); 76 | }; 77 | 78 | this.onRunComplete = () => undefined; 79 | } 80 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angularexampleapp", 3 | "version": "19.0.0", 4 | "scripts": { 5 | "prepare": "npx husky", 6 | "start": "ng serve --configuration=development-en --open", 7 | "start:es": "ng serve --configuration=development-es --open", 8 | "extract": "ng extract-i18n --format=xlf --output-path=src/locale", 9 | "lint": "ng lint && npm run stylelint", 10 | "stylelint": "npx stylelint \"**/*.{css,scss}\"", 11 | "test": "ng test --code-coverage --no-watch", 12 | "test:watch": "ng test --code-coverage", 13 | "e2e": "npm run playwright:install && npm run playwright:test", 14 | "playwright:install": "npx playwright install && npx playwright install-deps", 15 | "playwright:test": "npx playwright test", 16 | "build": "ng build && mv -v dist/angularexampleapp/browser/en/* dist/angularexampleapp/browser/ && rm -Rf dist/angularexampleapp/browser/en", 17 | "bundle-report": "ng build --configuration=production-sourcemaps && source-map-explorer dist/angularexampleapp/browser/**/*.js" 18 | }, 19 | "dependencies": { 20 | "@angular/animations": "19.2.5", 21 | "@angular/common": "19.2.5", 22 | "@angular/compiler": "19.2.5", 23 | "@angular/core": "19.2.5", 24 | "@angular/forms": "19.2.5", 25 | "@angular/localize": "19.2.5", 26 | "@angular/platform-browser": "19.2.5", 27 | "@angular/platform-browser-dynamic": "19.2.5", 28 | "@angular/router": "19.2.5", 29 | "ngx-progressbar": "14.0.0", 30 | "rxjs": "7.8.2", 31 | "tslib": "2.8.1" 32 | }, 33 | "devDependencies": { 34 | "@angular-devkit/build-angular": "19.2.6", 35 | "@angular-eslint/builder": "19.3.0", 36 | "@angular-eslint/eslint-plugin": "19.3.0", 37 | "@angular-eslint/eslint-plugin-template": "19.3.0", 38 | "@angular-eslint/schematics": "19.3.0", 39 | "@angular-eslint/template-parser": "19.3.0", 40 | "@angular/cli": "19.2.6", 41 | "@angular/compiler-cli": "19.2.5", 42 | "@commitlint/cli": "19.8.0", 43 | "@commitlint/config-conventional": "19.8.0", 44 | "@eslint/js": "9.23.0", 45 | "@ngrx/component": "^19.1.0", 46 | "@playwright/test": "1.51.1", 47 | "@shoelace-style/shoelace": "2.20.1", 48 | "@types/jasmine": "5.1.7", 49 | "@types/validator": "13.12.3", 50 | "@typescript-eslint/eslint-plugin": "8.29.0", 51 | "@typescript-eslint/parser": "8.29.0", 52 | "@typescript-eslint/types": "8.29.0", 53 | "@typescript-eslint/utils": "8.29.0", 54 | "angular-eslint": "19.3.0", 55 | "eslint": "9.23.0", 56 | "eslint-config-prettier": "10.1.1", 57 | "eslint-plugin-eslint-comments": "3.2.0", 58 | "eslint-plugin-prettier": "5.2.6", 59 | "eslint-plugin-promise": "7.2.1", 60 | "eslint-plugin-unicorn": "58.0.0", 61 | "husky": "9.1.7", 62 | "jasmine-core": "5.6.0", 63 | "karma": "6.4.4", 64 | "karma-chrome-launcher": "3.2.0", 65 | "karma-coverage": "2.2.1", 66 | "karma-jasmine": "5.1.0", 67 | "karma-jasmine-html-reporter": "2.1.0", 68 | "ng-extract-i18n-merge": "2.14.3", 69 | "normalize.css": "8.0.1", 70 | "playwright": "1.51.1", 71 | "postcss-scss": "4.0.9", 72 | "prettier": "3.5.3", 73 | "prettier-eslint": "16.3.0", 74 | "source-map-explorer": "2.5.3", 75 | "stylelint": "16.17.0", 76 | "stylelint-config-recess-order": "6.0.0", 77 | "stylelint-config-standard-scss": "14.0.0", 78 | "stylelint-order": "6.0.4", 79 | "ts-loader": "9.5.2", 80 | "typescript": "5.8.2", 81 | "typescript-eslint": "8.29.0", 82 | "webpack-bundle-analyzer": "4.10.2" 83 | }, 84 | "private": true 85 | } 86 | -------------------------------------------------------------------------------- /playwright.config.ts: -------------------------------------------------------------------------------- 1 | import { devices, PlaywrightTestConfig } from '@playwright/test'; 2 | 3 | const config: PlaywrightTestConfig = { 4 | testDir: './e2e', 5 | timeout: 30 * 1000, 6 | expect: { 7 | timeout: 5000, 8 | }, 9 | fullyParallel: false, 10 | forbidOnly: true, 11 | retries: 2, 12 | workers: 1, 13 | reporter: 'html', 14 | use: { 15 | actionTimeout: 0, 16 | trace: 'on-first-retry', 17 | }, 18 | projects: [ 19 | { 20 | name: 'Google Chrome', 21 | use: { 22 | channel: 'chrome', 23 | }, 24 | }, 25 | { 26 | name: 'Safari', 27 | use: { 28 | ...devices['Desktop Safari'], 29 | }, 30 | }, 31 | { 32 | name: 'Mobile Chrome', 33 | use: { 34 | ...devices['Pixel 5'], 35 | }, 36 | }, 37 | { 38 | name: 'Mobile Safari', 39 | use: { 40 | ...devices['iPhone 12'], 41 | }, 42 | }, 43 | ], 44 | }; 45 | 46 | export default config; 47 | -------------------------------------------------------------------------------- /public/_redirects: -------------------------------------------------------------------------------- 1 | /es /es/index.html 200 2 | /es/* /es 200 3 | /* /index.html 200 4 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 |
7 | 8 |
9 | 10 | 11 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/app/app.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | $page-max-width: 2560px; 4 | 5 | :host { 6 | display: flex; 7 | flex-direction: row; 8 | align-items: flex-start; 9 | min-height: 100vh; 10 | margin-inline: auto; 11 | 12 | .app__main-container { 13 | display: flex; 14 | flex-direction: column; 15 | width: 100%; 16 | min-height: 100vh; 17 | 18 | .app__page-container { 19 | display: grid; 20 | } 21 | } 22 | 23 | .app__content-skip-button { 24 | position: absolute; 25 | top: var(--spacing-r-md); 26 | left: var(--spacing-r-md); 27 | z-index: var(--z-index-skip-button); 28 | padding: var(--spacing-r-md); 29 | font-size: var(--font-size-xs); 30 | color: var(--page-background); 31 | background: var(--primary-contrast); 32 | border: 1px solid var(--brand-color-tertiary); 33 | border-radius: var(--border-radius-sm); 34 | transform: translateY(-150%); 35 | transition: transform 0.3s ease-out; 36 | 37 | &:focus { 38 | transform: translateY(0); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import type { ComponentFixture } from '@angular/core/testing'; 2 | import { TestBed } from '@angular/core/testing'; 3 | import { AppComponent } from './app.component'; 4 | import { 5 | Component, 6 | provideExperimentalZonelessChangeDetection, 7 | ChangeDetectionStrategy, 8 | } from '@angular/core'; 9 | import { HeaderService } from '~core/services/ui/header.service'; 10 | import { ENVIRONMENT } from '~core/tokens/environment.token'; 11 | import { provideHttpClientTesting } from '@angular/common/http/testing'; 12 | import { provideHttpClient } from '@angular/common/http'; 13 | import { HeaderComponent } from '~core/components/header/header.component'; 14 | 15 | @Component({ 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | selector: 'app-header', 18 | template: '', 19 | }) 20 | class HeaderStubComponent {} 21 | 22 | describe('AppComponent', () => { 23 | let component: AppComponent; 24 | let fixture: ComponentFixture; 25 | let setCanonicalSpy: jasmine.Spy; 26 | 27 | beforeEach(async () => { 28 | await TestBed.configureTestingModule({ 29 | imports: [AppComponent], 30 | providers: [ 31 | provideExperimentalZonelessChangeDetection(), 32 | provideHttpClient(), 33 | provideHttpClientTesting(), 34 | { provide: ENVIRONMENT, useValue: { domain: 'localhost' } }, 35 | HeaderService, 36 | ] 37 | }) 38 | .overrideComponent(AppComponent, { 39 | remove: { 40 | imports: [HeaderComponent], 41 | }, 42 | add: { 43 | imports: [HeaderStubComponent], 44 | } 45 | }) 46 | .compileComponents(); 47 | 48 | fixture = TestBed.createComponent(AppComponent); 49 | fixture.autoDetectChanges(); 50 | component = fixture.componentInstance; 51 | 52 | const headerService = TestBed.inject(HeaderService); 53 | setCanonicalSpy = spyOn(headerService, 'setCanonical').and.returnValue(); 54 | await fixture.whenStable(); 55 | }); 56 | 57 | it('should create', () => { 58 | expect(component).toBeDefined(); 59 | expect(setCanonicalSpy).not.toHaveBeenCalled(); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; 2 | import { translations } from '../locale/translations'; 3 | import { NavigationEnd, Router, RouterOutlet } from '@angular/router'; 4 | import { Title } from '@angular/platform-browser'; 5 | import { HeaderComponent } from '~core/components/header/header.component'; 6 | import { FooterComponent } from '~core/components/footer/footer.component'; 7 | import { DOCUMENT } from '@angular/common'; 8 | import { filter, map } from 'rxjs'; 9 | import { HeaderService } from '~core/services/ui/header.service'; 10 | import { ProgressBarComponent } from '~core/components/progress-bar/progress-bar.component'; 11 | import { CookiePopupComponent } from '~core/components/cookie-popup/cookie-popup.component'; 12 | import { toSignal } from '@angular/core/rxjs-interop'; 13 | import { ToastStackComponent } from '~core/components/toast-stack/toast-stack.component'; 14 | 15 | @Component({ 16 | selector: 'app-root', 17 | templateUrl: './app.component.html', 18 | styleUrl: './app.component.scss', 19 | changeDetection: ChangeDetectionStrategy.OnPush, 20 | imports: [ 21 | RouterOutlet, 22 | HeaderComponent, 23 | FooterComponent, 24 | ProgressBarComponent, 25 | CookiePopupComponent, 26 | ToastStackComponent, 27 | ], 28 | }) 29 | export class AppComponent { 30 | private readonly document = inject(DOCUMENT); 31 | private readonly router = inject(Router); 32 | private readonly titleService = inject(Title); 33 | private readonly headerService = inject(HeaderService); 34 | 35 | readonly currentUrl = toSignal( 36 | this.router.events.pipe( 37 | filter((event): event is NavigationEnd => event instanceof NavigationEnd), 38 | map((event) => event.urlAfterRedirects), 39 | ), 40 | { initialValue: this.router.url }, 41 | ); 42 | 43 | constructor() { 44 | this.titleService.setTitle(translations.title); 45 | 46 | effect(() => { 47 | const url = this.currentUrl(); 48 | this.headerService.setCanonical(url); 49 | }); 50 | } 51 | 52 | focusFirstHeading(): void { 53 | const h1 = this.document.querySelector('h1'); 54 | h1?.focus(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/app.config.ts: -------------------------------------------------------------------------------- 1 | import type { ApplicationConfig } from '@angular/core'; 2 | import { inject, provideExperimentalZonelessChangeDetection } from '@angular/core'; 3 | import { 4 | createUrlTreeFromSnapshot, 5 | PreloadAllModules, 6 | provideRouter, 7 | Router, 8 | withComponentInputBinding, 9 | withInMemoryScrolling, 10 | withPreloading, 11 | withRouterConfig, 12 | withViewTransitions, 13 | } from '@angular/router'; 14 | import { provideHttpClient, withFetch, withInterceptors } from '@angular/common/http'; 15 | import { cachingInterceptor } from '~core/interceptors/caching.interceptor'; 16 | import { appRoutes } from './app.routes'; 17 | import { provideAnimationsAsync } from '@angular/platform-browser/animations/async'; 18 | import { authenticationInterceptor } from '~core/interceptors/authentication.interceptor'; 19 | import { provideCloudinaryLoader } from '@angular/common'; 20 | import { ENVIRONMENT } from '~core/tokens/environment.token'; 21 | import { environment } from '~environments/environment'; 22 | 23 | export const appConfig: ApplicationConfig = { 24 | providers: [ 25 | { 26 | provide: ENVIRONMENT, 27 | useValue: environment, 28 | }, 29 | provideExperimentalZonelessChangeDetection(), 30 | provideRouter( 31 | appRoutes, 32 | withInMemoryScrolling(), 33 | withViewTransitions({ 34 | onViewTransitionCreated: ({ transition, to }) => { 35 | const router = inject(Router); 36 | const toTree = createUrlTreeFromSnapshot(to, []); 37 | // Skip the transition if the only thing changing is the fragment and queryParams 38 | if ( 39 | router.isActive(toTree, { 40 | paths: 'exact', 41 | matrixParams: 'exact', 42 | fragment: 'ignored', 43 | queryParams: 'ignored', 44 | }) 45 | ) { 46 | transition.skipTransition(); 47 | } 48 | }, 49 | }), 50 | withComponentInputBinding(), 51 | withRouterConfig({ paramsInheritanceStrategy: 'always', onSameUrlNavigation: 'reload' }), 52 | withPreloading(PreloadAllModules), 53 | ), 54 | provideHttpClient( 55 | withFetch(), 56 | withInterceptors([authenticationInterceptor, cachingInterceptor]), 57 | ), 58 | provideAnimationsAsync(), 59 | provideCloudinaryLoader('https://res.cloudinary.com/ismaestro/'), 60 | ], 61 | }; 62 | -------------------------------------------------------------------------------- /src/app/app.routes.ts: -------------------------------------------------------------------------------- 1 | import { AUTHENTICATION_PATHS, POKEMON_PATHS, ROOT_PATHS } from '~core/constants/paths.constants'; 2 | import { Error404Component } from '~core/components/error-404/error-404.component'; 3 | import type { Route } from '@angular/router'; 4 | import { HomeComponent } from '~features/home/home.component'; 5 | import { MyPokemonComponent } from '~features/pokemon/pages/my-pokemon/my-pokemon.component'; 6 | import { authenticationGuard } from '~core/guards/authentication.guard'; 7 | 8 | export const appRoutes: Route[] = [ 9 | { 10 | path: ROOT_PATHS.home, 11 | component: HomeComponent, 12 | }, 13 | { 14 | path: AUTHENTICATION_PATHS.base, 15 | loadChildren: async () => 16 | import('./features/authentication/authentication.routes').then( 17 | (module_) => module_.AUTHENTICATION_ROUTES, 18 | ), 19 | }, 20 | { 21 | path: ROOT_PATHS.myPokemon, 22 | component: MyPokemonComponent, 23 | canActivate: [authenticationGuard], 24 | }, 25 | { 26 | path: POKEMON_PATHS.base, 27 | loadChildren: async () => 28 | import('./features/pokemon/pokemon.routes').then((module_) => module_.POKEMON_ROUTES), 29 | }, 30 | { path: '404', component: Error404Component }, 31 | { path: '**', redirectTo: '404' }, 32 | ]; 33 | -------------------------------------------------------------------------------- /src/app/core/components/card/card.component.html: -------------------------------------------------------------------------------- 1 | @if (href()) { 2 | 3 | 4 | 5 | } @else { 6 |
7 | 8 |
9 | } 10 | 11 | 12 |
13 | 14 |
15 | 16 | 19 |
20 | -------------------------------------------------------------------------------- /src/app/core/components/card/card.component.scss: -------------------------------------------------------------------------------- 1 | :host { 2 | .card__container { 3 | display: flex; 4 | flex-direction: column; 5 | justify-content: space-between; 6 | padding: var(--spacing-r-3xl); 7 | overflow: hidden; 8 | color: var(--primary-contrast); 9 | border: 1px solid var(--senary-contrast); 10 | border-radius: var(--border-radius-sm); 11 | transition: 12 | border-color 0.3s ease, 13 | background-color 0.3s ease; 14 | 15 | &:hover { 16 | .card__link { 17 | background-position: 0 0; 18 | } 19 | 20 | background: var(--card-background-hover); 21 | } 22 | 23 | * + *:not(a, code, span), 24 | .card__heading { 25 | margin-block-end: var(--spacing-r-3xl); 26 | } 27 | 28 | .card__link { 29 | position: relative; 30 | margin-block: 0; 31 | font-size: var(--font-size-sm); 32 | color: transparent; 33 | background: var(--card-link-background-hover); 34 | background-position: 100% 0; 35 | background-clip: text; 36 | background-size: 200% 100%; 37 | transition: background-position 2s ease-out; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/app/core/components/card/card.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, input } from '@angular/core'; 2 | import { NgTemplateOutlet } from '@angular/common'; 3 | 4 | @Component({ 5 | selector: 'app-card', 6 | templateUrl: './card.component.html', 7 | styleUrl: './card.component.scss', 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | imports: [NgTemplateOutlet], 10 | }) 11 | export class CardComponent { 12 | readonly href = input(''); 13 | } 14 | -------------------------------------------------------------------------------- /src/app/core/components/cookie-popup/cookie-popup.component.html: -------------------------------------------------------------------------------- 1 | @if (!hasAccepted()) { 2 |
3 |

4 | This site uses Google Tag Manager and Google Analytics to make it work smoothly and to 5 | understand user behavior. 6 |

7 |
8 | Ok, got it! 16 |
17 |
18 | } 19 | -------------------------------------------------------------------------------- /src/app/core/components/cookie-popup/cookie-popup.component.scss: -------------------------------------------------------------------------------- 1 | $popup-max-width: 265px; 2 | 3 | :host { 4 | position: fixed; 5 | right: var(--spacing-r-md); 6 | bottom: var(--spacing-r-md); 7 | z-index: var(--z-index-cookie-consent); 8 | visibility: hidden; 9 | opacity: 0; 10 | animation: 1s linear forwards 0.5s fade-in; 11 | 12 | .cookies__container { 13 | max-width: $popup-max-width; 14 | padding: var(--spacing-r-xl); 15 | font-size: var(--font-size-sm); 16 | background-color: var(--page-background); 17 | border: 1px solid var(--senary-contrast); 18 | border-radius: var(--border-radius-sm); 19 | box-shadow: 0 0 10px 0 rgb(0 0 0 / 10%); 20 | transition: 21 | background-color 0.3s ease, 22 | border-color 0.3s ease, 23 | color 0.3s ease; 24 | 25 | > div { 26 | display: flex; 27 | gap: var(--spacing-r-md); 28 | align-items: center; 29 | width: 100%; 30 | margin-block-start: var(--spacing-r-xl); 31 | } 32 | 33 | p { 34 | margin-block: 0; 35 | color: var(--primary-contrast); 36 | } 37 | } 38 | 39 | @keyframes fade-in { 40 | 100% { 41 | visibility: visible; 42 | opacity: 1; 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/app/core/components/cookie-popup/cookie-popup.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | CUSTOM_ELEMENTS_SCHEMA, 5 | inject, 6 | signal, 7 | } from '@angular/core'; 8 | import { ConsentState, CookieConsentService } from '~core/services/storage/cookie-consent.service'; 9 | 10 | import '@shoelace-style/shoelace/dist/components/button/button.js'; 11 | 12 | @Component({ 13 | selector: 'app-cookie-popup', 14 | templateUrl: './cookie-popup.component.html', 15 | styleUrl: './cookie-popup.component.scss', 16 | changeDetection: ChangeDetectionStrategy.OnPush, 17 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 18 | }) 19 | export class CookiePopupComponent { 20 | private readonly cookieConsentService = inject(CookieConsentService); 21 | 22 | readonly hasAccepted = signal(this.cookieConsentService.getCookieState()); 23 | 24 | acceptCookies(): void { 25 | const cookieSaved = this.cookieConsentService.setCookieConsent(ConsentState.GRANTED); 26 | if (cookieSaved) { 27 | this.hasAccepted.set(true); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/app/core/components/decorative-header/decorative-header.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 |
5 |
6 |
7 | -------------------------------------------------------------------------------- /src/app/core/components/decorative-header/decorative-header.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | $svg-width: 200px; 4 | 5 | :host { 6 | .decorative-header__container { 7 | position: relative; 8 | display: flex; 9 | flex-direction: column-reverse; 10 | padding: var(--spacing-r-3xl); 11 | margin-block-end: var(--spacing-r-4xl); 12 | overflow: hidden; 13 | background: var(--septenary-contrast); 14 | border-radius: var(--border-radius-xl); 15 | transition: background 0.3s ease; 16 | 17 | @include mq.for-tablet-portrait-up { 18 | flex-direction: row; 19 | align-items: center; 20 | justify-content: space-between; 21 | } 22 | 23 | .decorative-header__image { 24 | width: $svg-width; 25 | height: auto; 26 | margin-bottom: var(--spacing-r-xl); 27 | 28 | // stylelint-disable selector-pseudo-element-no-unknown 29 | ::ng-deep svg { 30 | overflow: unset; 31 | } 32 | 33 | @include mq.for-tablet-portrait-up { 34 | margin-bottom: 0; 35 | } 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/core/components/decorative-header/decorative-header.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; 2 | import type { SafeHtml } from '@angular/platform-browser'; 3 | import { DomSanitizer } from '@angular/platform-browser'; 4 | import { FileService } from '~core/services/storage/file.service'; 5 | import { rxResource } from '@angular/core/rxjs-interop'; 6 | 7 | @Component({ 8 | selector: 'app-decorative-header', 9 | templateUrl: './decorative-header.component.html', 10 | styleUrl: './decorative-header.component.scss', 11 | changeDetection: ChangeDetectionStrategy.OnPush, 12 | }) 13 | export class DecorativeHeaderComponent { 14 | private readonly fileService = inject(FileService); 15 | private readonly domSanitizer = inject(DomSanitizer); 16 | 17 | readonly svgUrl = input(''); 18 | readonly svgResource = rxResource({ 19 | request: this.svgUrl, 20 | loader: ({ request }) => this.fileService.getFileAsText(request), 21 | }); 22 | readonly svgContent = computed(() => 23 | this.domSanitizer.bypassSecurityTrustHtml(this.svgResource.value()!), 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /src/app/core/components/error-404/error-404.component.html: -------------------------------------------------------------------------------- 1 |
2 |

Error 404: A Wild Error Appeared

3 | 4 |
5 | ash and pikachu sad 14 |
15 |
16 | -------------------------------------------------------------------------------- /src/app/core/components/error-404/error-404.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | @use 'components/pages'; 3 | 4 | :host { 5 | @include pages.read-page; 6 | 7 | text-align: center; 8 | 9 | img { 10 | width: 300px; 11 | height: auto; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/app/core/components/error-404/error-404.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | import { NgOptimizedImage } from '@angular/common'; 3 | 4 | @Component({ 5 | selector: 'app-error-404', 6 | templateUrl: './error-404.component.html', 7 | styleUrl: 'error-404.component.scss', 8 | changeDetection: ChangeDetectionStrategy.OnPush, 9 | imports: [NgOptimizedImage], 10 | }) 11 | export class Error404Component {} 12 | -------------------------------------------------------------------------------- /src/app/core/components/footer/footer.component.html: -------------------------------------------------------------------------------- 1 | 130 | -------------------------------------------------------------------------------- /src/app/core/components/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | :host { 4 | margin-top: auto; 5 | 6 | .footer-container { 7 | container: footer / inline-size; 8 | position: relative; 9 | justify-content: center; 10 | padding: var(--layout-padding); 11 | padding-inline-end: var(--spacing-r-xl); 12 | background-color: var(--page-background); 13 | transition: background-color 0.3s ease; 14 | 15 | .footer-columns { 16 | display: grid; 17 | grid-template-columns: repeat(2, 1fr); 18 | gap: var(--spacing-r-4xl); 19 | text-align: center; 20 | 21 | @include mq.for-tablet-up { 22 | grid-template-columns: repeat(4, 1fr); 23 | } 24 | 25 | h2 { 26 | margin-block-end: var(--spacing-r-3xl); 27 | font-size: var(--font-size-sm); 28 | font-weight: var(--font-weight-bold); 29 | letter-spacing: var(--letter-spacing-sm); 30 | } 31 | 32 | ul { 33 | display: flex; 34 | flex-direction: column; 35 | gap: var(--spacing-r-xl); 36 | padding: 0; 37 | list-style: none; 38 | 39 | li { 40 | font-size: var(--font-size-xs); 41 | } 42 | } 43 | 44 | a { 45 | font-weight: var(--font-weight-light); 46 | color: var(--quaternary-contrast); 47 | transition: color 0.3s ease; 48 | 49 | &:hover { 50 | color: var(--primary-contrast); 51 | } 52 | } 53 | } 54 | 55 | .licence__paragraph { 56 | grid-column: span 4; 57 | margin-block-start: var(--spacing-r-4xl); 58 | font-size: var(--font-size-xs); 59 | font-weight: var(--font-weight-light); 60 | color: var(--quaternary-contrast); 61 | text-align: center; 62 | transition: color 0.3s ease; 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/core/components/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer', 5 | templateUrl: './footer.component.html', 6 | styleUrl: './footer.component.scss', 7 | changeDetection: ChangeDetectionStrategy.OnPush, 8 | }) 9 | export class FooterComponent {} 10 | -------------------------------------------------------------------------------- /src/app/core/components/header/header.component.html: -------------------------------------------------------------------------------- 1 | 154 | -------------------------------------------------------------------------------- /src/app/core/components/header/header.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | $line-active-item-bottom: -31px; 4 | $avatar-size: 40px; 5 | 6 | :host { 7 | .nav__container { 8 | display: flex; 9 | flex-direction: column; 10 | align-items: center; 11 | justify-content: space-between; 12 | border-bottom: 1px solid var(--septenary-contrast); 13 | 14 | @include mq.for-tablet-up { 15 | flex-direction: row; 16 | align-items: center; 17 | justify-content: center; 18 | padding: 0 var(--spacing-r-xl); 19 | } 20 | 21 | &.open { 22 | padding-bottom: var(--spacing-3xl); 23 | 24 | @include mq.for-tablet-up { 25 | padding-bottom: 0; 26 | } 27 | } 28 | 29 | .nav-mobile__container { 30 | display: flex; 31 | align-items: center; 32 | justify-content: space-between; 33 | width: 100%; 34 | 35 | &.open { 36 | border-bottom: 1px solid var(--septenary-contrast); 37 | } 38 | 39 | .nav__hamburger { 40 | display: block; 41 | padding: var(--spacing-xl) var(--spacing-xl); 42 | margin-left: auto; 43 | } 44 | 45 | .nav__item:first-of-type { 46 | margin-left: var(--spacing-r-xl); 47 | } 48 | 49 | @include mq.for-tablet-up { 50 | display: none; 51 | } 52 | } 53 | 54 | .nav__items { 55 | display: none; 56 | flex-direction: column; 57 | gap: var(--spacing-r-lg); 58 | align-items: center; 59 | justify-content: center; 60 | width: 100%; 61 | color: inherit; 62 | text-decoration: none; 63 | 64 | @include mq.for-tablet-up { 65 | display: flex; 66 | flex-flow: row wrap; 67 | } 68 | 69 | &.open { 70 | display: flex; 71 | padding-top: 0; 72 | } 73 | 74 | @include mq.for-tablet-up { 75 | &.user-logged { 76 | .nav__item:nth-child(4) { 77 | margin-left: auto; 78 | } 79 | } 80 | 81 | &:not(.user-logged) { 82 | .nav__item:nth-child(5) { 83 | margin-left: auto; 84 | } 85 | } 86 | } 87 | 88 | .nav__item { 89 | display: block; 90 | margin: var(--spacing-md) 0; 91 | 92 | &:first-of-type { 93 | margin-top: var(--spacing-r-4xl); 94 | } 95 | 96 | @include mq.for-tablet-up { 97 | margin: var(--spacing-xxl) var(--spacing-lg); 98 | 99 | &:first-of-type { 100 | margin: 0 var(--spacing-md) 0; 101 | } 102 | 103 | &:last-of-type { 104 | margin-right: var(--spacing-sm); 105 | } 106 | 107 | &:nth-child(-n + 0) { 108 | align-self: flex-start; 109 | } 110 | } 111 | 112 | .nav__link { 113 | font-size: var(--font-size-md); 114 | color: var(--text-color-secondary); 115 | text-decoration: none; 116 | 117 | &:hover { 118 | color: var(--text-color-secondary-hover); 119 | } 120 | 121 | @include mq.for-tablet-up { 122 | &.active { 123 | position: relative; 124 | 125 | &::after { 126 | position: absolute; 127 | bottom: $line-active-item-bottom; 128 | left: 0; 129 | width: 100%; 130 | height: var(--spacing-xs); 131 | content: ''; 132 | background-color: var(--full-contrast); 133 | } 134 | } 135 | } 136 | 137 | img:first-of-type { 138 | margin: 0; 139 | } 140 | } 141 | } 142 | } 143 | 144 | .avatar__image { 145 | width: $avatar-size; 146 | height: $avatar-size; 147 | border-radius: 50%; 148 | } 149 | 150 | .github-logo__image { 151 | opacity: 0.7; 152 | 153 | &:hover { 154 | opacity: 1; 155 | } 156 | } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /src/app/core/components/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import type { ElementRef, Signal } from '@angular/core'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | CUSTOM_ELEMENTS_SCHEMA, 6 | inject, 7 | signal, 8 | viewChild, 9 | } from '@angular/core'; 10 | import { AUTH_URLS, ROOT_URLS } from '~core/constants/urls.constants'; 11 | import { Router, RouterLink, RouterLinkActive } from '@angular/router'; 12 | import { NgOptimizedImage, NgTemplateOutlet } from '@angular/common'; 13 | import { AuthenticationService } from '~features/authentication/services/authentication.service'; 14 | import { LanguageSelectorComponent } from '~core/components/language-selector/language-selector.component'; 15 | import { ThemeButtonComponent } from '~core/components/theme-button/theme-button.component'; 16 | import { ROOT_PATHS } from '~core/constants/paths.constants'; 17 | import { translations } from '../../../../locale/translations'; 18 | import type { SlDropdown } from '@shoelace-style/shoelace'; 19 | import { PokemonSearchInputComponent } from '~features/pokemon/components/pokemon-search-input/pokemon-search-input.component'; 20 | 21 | import '@shoelace-style/shoelace/dist/components/button/button.js'; 22 | import '@shoelace-style/shoelace/dist/components/icon/icon.js'; 23 | import '@shoelace-style/shoelace/dist/components/dropdown/dropdown.js'; 24 | 25 | @Component({ 26 | selector: 'app-header', 27 | templateUrl: './header.component.html', 28 | styleUrl: './header.component.scss', 29 | changeDetection: ChangeDetectionStrategy.OnPush, 30 | imports: [ 31 | RouterLink, 32 | RouterLinkActive, 33 | NgOptimizedImage, 34 | LanguageSelectorComponent, 35 | NgTemplateOutlet, 36 | ThemeButtonComponent, 37 | PokemonSearchInputComponent, 38 | ], 39 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 40 | }) 41 | export class HeaderComponent { 42 | private readonly authenticationService = inject(AuthenticationService); 43 | private readonly router = inject(Router); 44 | 45 | readonly ROOT_PATHS = ROOT_PATHS; 46 | readonly ROOT_URLS = ROOT_URLS; 47 | readonly AUTH_URLS = AUTH_URLS; 48 | readonly translations = translations; 49 | readonly avatarDropdown: Signal | undefined> = viewChild('avatarDropdown'); 50 | readonly isUserLoggedIn = () => this.authenticationService.authState().isLoggedIn; 51 | readonly menuOpen = signal(false); 52 | 53 | logOutUser() { 54 | this.closeMenu(); 55 | this.authenticationService.logOut(); 56 | void this.router.navigate([ROOT_URLS.home]); 57 | } 58 | 59 | toggleMenu() { 60 | this.menuOpen.set(!this.menuOpen()); 61 | } 62 | 63 | closeMenu() { 64 | void this.avatarDropdown()?.nativeElement.hide(); 65 | this.menuOpen.set(false); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/app/core/components/language-selector/language-selector.component.html: -------------------------------------------------------------------------------- 1 | 2 | {{ 3 | localeIdText() | uppercase 4 | }} 5 | 13 | 14 | -------------------------------------------------------------------------------- /src/app/core/components/language-selector/language-selector.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | CUSTOM_ELEMENTS_SCHEMA, 5 | inject, 6 | signal, 7 | } from '@angular/core'; 8 | import { Router } from '@angular/router'; 9 | import { UpperCasePipe } from '@angular/common'; 10 | import { LanguageService } from '~core/services/language.service'; 11 | 12 | import '@shoelace-style/shoelace/dist/components/dropdown/dropdown.js'; 13 | 14 | @Component({ 15 | selector: 'app-language-selector', 16 | templateUrl: './language-selector.component.html', 17 | changeDetection: ChangeDetectionStrategy.OnPush, 18 | imports: [UpperCasePipe], 19 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 20 | }) 21 | export class LanguageSelectorComponent { 22 | private readonly languageService = inject(LanguageService); 23 | 24 | readonly router = inject(Router); 25 | readonly localeIdText = signal(this.languageService.convertLocaleToAcceptLanguage()); 26 | } 27 | -------------------------------------------------------------------------------- /src/app/core/components/progress-bar/progress-bar.component.ts: -------------------------------------------------------------------------------- 1 | import type { Signal } from '@angular/core'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | effect, 6 | inject, 7 | signal, 8 | viewChild, 9 | } from '@angular/core'; 10 | import { NgProgressbar, NgProgressRef } from 'ngx-progressbar'; 11 | import type { RouterEvent } from '@angular/router'; 12 | import { 13 | NavigationCancel, 14 | NavigationEnd, 15 | NavigationError, 16 | NavigationSkipped, 17 | NavigationStart, 18 | Router, 19 | } from '@angular/router'; 20 | import { toSignal } from '@angular/core/rxjs-interop'; 21 | import { filter } from 'rxjs'; 22 | 23 | /** Delay before showing the progress bar */ 24 | export const PROGRESS_BAR_DELAY = 30; 25 | 26 | @Component({ 27 | selector: 'app-progress-bar', 28 | template: ``, 29 | imports: [NgProgressbar], 30 | changeDetection: ChangeDetectionStrategy.OnPush, 31 | }) 32 | export class ProgressBarComponent { 33 | private readonly router = inject(Router); 34 | private readonly progressBar: Signal = viewChild(NgProgressRef); 35 | private readonly routerEvents = toSignal( 36 | this.router.events.pipe(filter((event) => this.isNavigationEvent(event as RouterEvent))), 37 | ) as Signal; 38 | private readonly timeoutId = signal(null); 39 | 40 | constructor() { 41 | effect(() => { 42 | const event = this.routerEvents(); 43 | 44 | if (event instanceof NavigationStart) { 45 | const id = setTimeout(() => { 46 | this.progressBar()?.start(); 47 | }, PROGRESS_BAR_DELAY) as unknown as number; 48 | 49 | this.timeoutId.set(id); 50 | } 51 | 52 | if (this.isNavigationEndLike(event)) { 53 | const id = this.timeoutId(); 54 | if (id !== null) { 55 | clearTimeout(id); 56 | } 57 | this.progressBar()?.complete(); 58 | this.timeoutId.set(null); 59 | } 60 | }); 61 | } 62 | 63 | private isNavigationEvent(event: RouterEvent): boolean { 64 | return event instanceof NavigationStart || this.isNavigationEndLike(event); 65 | } 66 | 67 | private isNavigationEndLike(event: RouterEvent): boolean { 68 | return ( 69 | event instanceof NavigationEnd || 70 | event instanceof NavigationCancel || 71 | event instanceof NavigationSkipped || 72 | event instanceof NavigationError 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/app/core/components/theme-button/theme-button.component.html: -------------------------------------------------------------------------------- 1 | 2 | @if (themeSelected() === Theme.DARK) { 3 | 4 | } @else { 5 | 6 | } 7 | 8 | -------------------------------------------------------------------------------- /src/app/core/components/theme-button/theme-button.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, inject } from '@angular/core'; 2 | import { Theme, ThemeManagerService } from '~core/services/ui/theme-manager.service'; 3 | 4 | import '@shoelace-style/shoelace/dist/components/button/button.js'; 5 | import '@shoelace-style/shoelace/dist/components/icon/icon.js'; 6 | 7 | @Component({ 8 | selector: 'app-theme-button', 9 | templateUrl: './theme-button.component.html', 10 | changeDetection: ChangeDetectionStrategy.OnPush, 11 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 12 | }) 13 | export class ThemeButtonComponent { 14 | private readonly themeManagerService = inject(ThemeManagerService); 15 | 16 | readonly themeSelected = this.themeManagerService.themeSelected; 17 | readonly Theme = Theme; 18 | 19 | toggleTheme() { 20 | if (this.themeSelected() === Theme.DARK) { 21 | this.themeManagerService.setTheme(Theme.LIGHT); 22 | } else { 23 | this.themeManagerService.setTheme(Theme.DARK); 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/core/components/toast-stack/toast-stack.component.html: -------------------------------------------------------------------------------- 1 | @for (alert of alerts(); track alert.id) { 2 | 14 | } 15 | -------------------------------------------------------------------------------- /src/app/core/components/toast-stack/toast-stack.component.ts: -------------------------------------------------------------------------------- 1 | import type { ElementRef } from '@angular/core'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | CUSTOM_ELEMENTS_SCHEMA, 6 | effect, 7 | inject, 8 | viewChildren, 9 | } from '@angular/core'; 10 | import { AlertStore } from '~core/services/ui/alert.store'; 11 | import type { Alert } from '~core/constants/alerts.constants'; 12 | 13 | import '@shoelace-style/shoelace/dist/components/alert/alert.js'; 14 | 15 | @Component({ 16 | selector: 'app-toast-stack', 17 | templateUrl: './toast-stack.component.html', 18 | changeDetection: ChangeDetectionStrategy.OnPush, 19 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 20 | }) 21 | export class ToastStackComponent { 22 | private readonly alertStore = inject(AlertStore); 23 | private readonly toastedAlertIds = new Set(); 24 | private readonly alertElements = viewChildren('alertReference'); 25 | 26 | readonly alerts = this.alertStore.alerts; 27 | 28 | constructor() { 29 | effect(() => { 30 | for (const element of this.alertElements()) { 31 | const native = element.nativeElement as HTMLElement & { toast?: () => void }; 32 | const alertId = native.getAttribute('id'); 33 | if (alertId && !this.toastedAlertIds.has(alertId)) { 34 | native.toast?.(); 35 | this.toastedAlertIds.add(alertId); 36 | } 37 | } 38 | }); 39 | } 40 | 41 | removeFromAlerts(alert: Alert) { 42 | this.alertStore.removeAlert(alert); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/app/core/constants/alerts.constants.ts: -------------------------------------------------------------------------------- 1 | export enum AlertType { 2 | SUCCESS = 'success', 3 | ERROR = 'error', 4 | } 5 | 6 | export type Alert = { 7 | id: string; 8 | message: string; 9 | type: AlertType; 10 | hasCountdown?: boolean; 11 | duration?: number; 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/core/constants/api-error-codes.constants.ts: -------------------------------------------------------------------------------- 1 | export const API_ERROR_CODES = { 2 | INVALID_CREDENTIALS_CODE: 2002, 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/core/constants/endpoints.constants.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import type { Environment } from '~core/tokens/environment.token'; 3 | import { ENVIRONMENT } from '~core/tokens/environment.token'; 4 | 5 | export const getEndpoints = () => { 6 | const environment = inject(ENVIRONMENT); 7 | const POKEMON_API_HOST = 'https://pokeapi.co/api'; 8 | return { 9 | auth: { 10 | v1: { 11 | authentication: `${environment.apiBaseUrl}/v1/authentication`, 12 | login: `${environment.apiBaseUrl}/v1/authentication/login`, 13 | refreshToken: `${environment.apiBaseUrl}/v1/authentication/token/refresh`, 14 | }, 15 | }, 16 | user: { 17 | v1: { 18 | user: `${environment.apiBaseUrl}/v1/user`, 19 | pokemonCatch: `${environment.apiBaseUrl}/v1/user/pokemon/catch`, 20 | }, 21 | }, 22 | pokemon: { 23 | v1: { 24 | pokemon: (pokemonIdOrName: string | number) => 25 | `${POKEMON_API_HOST}/v2/pokemon/${pokemonIdOrName}`, 26 | }, 27 | }, 28 | analytics: { 29 | v1: { 30 | realtimeUsers: `${environment.apiBaseUrl}/v1/analytics/realtime-users`, 31 | }, 32 | }, 33 | } as const; 34 | }; 35 | -------------------------------------------------------------------------------- /src/app/core/constants/language.constants.ts: -------------------------------------------------------------------------------- 1 | import { Locale } from '~core/enums/locale.enum'; 2 | 3 | export const DEFAULT_LOCALE = Locale.EN; 4 | -------------------------------------------------------------------------------- /src/app/core/constants/paths.constants.ts: -------------------------------------------------------------------------------- 1 | export const ROOT_PATHS = { 2 | home: '', 3 | myPokemon: 'my-pokemon', 4 | error404: '404', 5 | }; 6 | 7 | export const AUTHENTICATION_PATHS = { 8 | base: 'auth', 9 | logIn: 'log-in', 10 | register: 'register', 11 | myAccount: 'my-account', 12 | }; 13 | 14 | export const POKEMON_PATHS = { 15 | base: 'pokemon', 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/core/constants/urls.constants.ts: -------------------------------------------------------------------------------- 1 | import { AUTHENTICATION_PATHS, POKEMON_PATHS, ROOT_PATHS } from '~core/constants/paths.constants'; 2 | 3 | export const ROOT_URLS = { 4 | home: `/${ROOT_PATHS.home}`, 5 | myPokedex: `/${ROOT_PATHS.myPokemon}`, 6 | error404: `/${ROOT_PATHS.error404}`, 7 | }; 8 | 9 | export const AUTH_URLS = { 10 | logIn: `/${AUTHENTICATION_PATHS.base}/${AUTHENTICATION_PATHS.logIn}`, 11 | register: `/${AUTHENTICATION_PATHS.base}/${AUTHENTICATION_PATHS.register}`, 12 | myAccount: `/${AUTHENTICATION_PATHS.base}/${AUTHENTICATION_PATHS.myAccount}`, 13 | }; 14 | 15 | export const POKEMON_URLS = { 16 | detail: (id: string) => `/${POKEMON_PATHS.base}/${id}`, 17 | }; 18 | -------------------------------------------------------------------------------- /src/app/core/directives/lowercase.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, inject } from '@angular/core'; 2 | import { NgControl } from '@angular/forms'; 3 | 4 | @Directive({ 5 | selector: '[appLowercase]', 6 | host: { 7 | '(keydown)': 'onKeyDown()', 8 | }, 9 | }) 10 | export class LowercaseDirective { 11 | private readonly el = inject(ElementRef); 12 | private readonly ngControl = inject(NgControl); 13 | 14 | onKeyDown() { 15 | const { control } = this.ngControl; 16 | if (control) { 17 | control.setValue(this.el.nativeElement.value.toLowerCase()); 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/core/directives/sl-checkbox-control.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, inject, model } from '@angular/core'; 2 | import type { ControlValueAccessor } from '@angular/forms'; 3 | import { NG_VALUE_ACCESSOR } from '@angular/forms'; 4 | 5 | @Directive({ 6 | selector: '[appSlCheckboxControl]', 7 | providers: [ 8 | { 9 | provide: NG_VALUE_ACCESSOR, 10 | useExisting: AppSlCheckboxControlDirective, 11 | multi: true, 12 | }, 13 | ], 14 | host: { 15 | '[attr.checked]': 'checked()', 16 | '(sl-change)': 'onSlChange()', 17 | }, 18 | }) 19 | export class AppSlCheckboxControlDirective implements ControlValueAccessor { 20 | private readonly el = inject(ElementRef); 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars 23 | private onChangeFn = (value: boolean) => {}; 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-empty-function 26 | private onTouchedFn = () => {}; 27 | 28 | readonly checked = model(false); 29 | 30 | writeValue(value: boolean): void { 31 | this.checked.set(value); 32 | } 33 | 34 | registerOnChange(function_: () => void): void { 35 | this.onChangeFn = function_; 36 | } 37 | 38 | registerOnTouched(function_: () => void): void { 39 | this.onTouchedFn = function_; 40 | } 41 | 42 | onSlChange(): void { 43 | const { checked } = this.el.nativeElement; 44 | this.onChangeFn(checked); 45 | this.onTouchedFn(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/core/directives/sl-input-icon-focus.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, inject } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[appSlInputIconFocus]', 5 | host: { 6 | '(mouseover)': 'onMouseOver()', 7 | '(mouseout)': 'onMouseOut()', 8 | '(focus)': 'onFocus()', 9 | '(blur)': 'onBlur()', 10 | }, 11 | }) 12 | export class SlInputIconFocusDirective { 13 | private readonly el = inject(ElementRef); 14 | 15 | private isFocused = false; 16 | 17 | onMouseOver() { 18 | this.el.nativeElement.querySelector('sl-icon').style.color = 'var(--primary-contrast)'; 19 | } 20 | 21 | onMouseOut() { 22 | if (!this.isFocused) { 23 | this.el.nativeElement.querySelector('sl-icon').style.color = 'var(--quaternary-contrast)'; 24 | } 25 | } 26 | 27 | onFocus() { 28 | this.isFocused = true; 29 | this.el.nativeElement.querySelector('sl-icon').style.color = 'var(--primary-contrast)'; 30 | } 31 | 32 | onBlur() { 33 | this.isFocused = false; 34 | this.el.nativeElement.querySelector('sl-icon').style.color = 'var(--quaternary-contrast)'; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/core/directives/sl-select-control.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, inject, model } from '@angular/core'; 2 | import type { ControlValueAccessor } from '@angular/forms'; 3 | import { NG_VALUE_ACCESSOR } from '@angular/forms'; 4 | 5 | @Directive({ 6 | selector: '[appSlSelectControl]', 7 | providers: [ 8 | { 9 | provide: NG_VALUE_ACCESSOR, 10 | useExisting: AppSlSelectControlDirective, 11 | multi: true, 12 | }, 13 | ], 14 | host: { 15 | '[attr.value]': 'value()', 16 | '(sl-change)': 'onSlChange()', 17 | }, 18 | }) 19 | export class AppSlSelectControlDirective implements ControlValueAccessor { 20 | private readonly el = inject(ElementRef); 21 | 22 | // eslint-disable-next-line @typescript-eslint/no-empty-function,@typescript-eslint/no-unused-vars 23 | private onChangeFn = (value: unknown) => {}; 24 | 25 | // eslint-disable-next-line @typescript-eslint/no-empty-function 26 | private onTouchedFn = () => {}; 27 | 28 | readonly value = model(''); 29 | 30 | writeValue(value: string): void { 31 | this.value.set(value); 32 | } 33 | 34 | registerOnChange(function_: () => void): void { 35 | this.onChangeFn = function_; 36 | } 37 | 38 | registerOnTouched(function_: () => void): void { 39 | this.onTouchedFn = function_; 40 | } 41 | 42 | onSlChange(): void { 43 | const { value } = this.el.nativeElement; 44 | this.onChangeFn(value); 45 | this.onTouchedFn(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/app/core/directives/trim.directive.ts: -------------------------------------------------------------------------------- 1 | import { Directive, ElementRef, inject } from '@angular/core'; 2 | 3 | @Directive({ 4 | selector: '[appTrim]', 5 | host: { 6 | '(blur)': 'onBlur()', 7 | }, 8 | }) 9 | export class TrimDirective { 10 | private readonly el = inject(ElementRef); 11 | 12 | onBlur() { 13 | this.el.nativeElement.value = this.el.nativeElement.value.trim(); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/core/enums/app-error.enum.ts: -------------------------------------------------------------------------------- 1 | export enum AppError { 2 | // Token Errors (3000–3999) 3 | ACCESS_TOKEN_NOT_FOUND = 3000, 4 | REFRESH_TOKEN_NOT_FOUND = 3001, 5 | ACCESS_TOKEN_EXPIRED = 3002, 6 | REFRESH_TOKEN_EXPIRED = 3003, 7 | } 8 | -------------------------------------------------------------------------------- /src/app/core/enums/language.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Language { 2 | EN_US = 'en-US', 3 | ES_ES = 'es-ES', 4 | } 5 | -------------------------------------------------------------------------------- /src/app/core/enums/locale.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Locale { 2 | EN = 'en', 3 | ES = 'es', 4 | } 5 | -------------------------------------------------------------------------------- /src/app/core/guards/authentication.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { AUTH_URLS } from '~core/constants/urls.constants'; 4 | import { AuthenticationService } from '~features/authentication/services/authentication.service'; 5 | 6 | export function authenticationGuard(): boolean { 7 | const authenticationService = inject(AuthenticationService); 8 | const router = inject(Router); 9 | 10 | if (authenticationService.authState().isLoggedIn) { 11 | return true; 12 | } 13 | 14 | void router.navigate([AUTH_URLS.logIn]); 15 | return false; 16 | }; 17 | -------------------------------------------------------------------------------- /src/app/core/guards/no-authentication.guard.ts: -------------------------------------------------------------------------------- 1 | import { inject } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { AuthenticationService } from '~features/authentication/services/authentication.service'; 4 | import { ROOT_PATHS } from '~core/constants/paths.constants'; 5 | 6 | export function noAuthenticationGuard(): boolean { 7 | const authenticationService = inject(AuthenticationService); 8 | const router = inject(Router); 9 | 10 | if (authenticationService.authState().isLoggedIn) { 11 | void router.navigate([ROOT_PATHS.home]); 12 | return false; 13 | } 14 | 15 | return true; 16 | } 17 | -------------------------------------------------------------------------------- /src/app/core/interceptors/caching.interceptor.ts: -------------------------------------------------------------------------------- 1 | import type { HttpEvent, HttpHandlerFn, HttpRequest } from '@angular/common/http'; 2 | import { HttpContextToken, HttpResponse } from '@angular/common/http'; 3 | import type { Observable } from 'rxjs'; 4 | import { of, tap } from 'rxjs'; 5 | 6 | export const CACHING_ENABLED = new HttpContextToken(() => false); 7 | 8 | const cache = new Map>(); 9 | 10 | export function cachingInterceptor( 11 | request: HttpRequest, 12 | next: HttpHandlerFn, 13 | ): Observable> { 14 | if (request.context.get(CACHING_ENABLED)) { 15 | const cachedResponse = cache.get(request.urlWithParams); 16 | if (cachedResponse) { 17 | return of(cachedResponse.clone()); 18 | } 19 | 20 | return next(request).pipe( 21 | tap((event) => { 22 | if (event instanceof HttpResponse) { 23 | cache.set(request.urlWithParams, event.clone()); 24 | } 25 | }), 26 | ); 27 | } 28 | 29 | return next(request); 30 | } 31 | 32 | export function clearCache() { 33 | cache.clear(); 34 | } 35 | -------------------------------------------------------------------------------- /src/app/core/pipes/first-title-case.pipe.ts: -------------------------------------------------------------------------------- 1 | import type { PipeTransform } from '@angular/core'; 2 | import { Pipe } from '@angular/core'; 3 | 4 | @Pipe({ 5 | name: 'firstTitleCase', 6 | }) 7 | export class FirstTitleCasePipe implements PipeTransform { 8 | transform(value: string | undefined): string { 9 | return value ? value[0].toUpperCase() + value.slice(1) : ''; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/app/core/providers/local-storage.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { inject, InjectionToken, PLATFORM_ID } from '@angular/core'; 3 | 4 | /** 5 | * LocalStorage is wrapper class for localStorage, operations can fail due to various reasons, 6 | * such as browser restrictions or storage limits being exceeded. A wrapper is providing error handling. 7 | */ 8 | class LocalStorage implements Storage { 9 | get length(): number { 10 | try { 11 | return localStorage.length; 12 | } catch { 13 | return 0; 14 | } 15 | } 16 | 17 | clear(): void { 18 | try { 19 | localStorage.clear(); 20 | } catch { 21 | /* Empty */ 22 | } 23 | } 24 | 25 | getItem(key: string): string | null { 26 | try { 27 | return localStorage.getItem(key); 28 | } catch { 29 | return null; 30 | } 31 | } 32 | 33 | key(index: number): string | null { 34 | try { 35 | return localStorage.key(index); 36 | } catch { 37 | return null; 38 | } 39 | } 40 | 41 | removeItem(key: string): void { 42 | try { 43 | localStorage.removeItem(key); 44 | } catch { 45 | /* Empty */ 46 | } 47 | } 48 | 49 | setItem(key: string, value: string): void { 50 | try { 51 | localStorage.setItem(key, value); 52 | } catch { 53 | /* Empty */ 54 | } 55 | } 56 | } 57 | 58 | const getStorage = (platformId: object): Storage | null => 59 | isPlatformBrowser(platformId) ? new LocalStorage() : null; 60 | 61 | export const LOCAL_STORAGE = new InjectionToken('LOCAL_STORAGE', { 62 | providedIn: 'root', 63 | factory: () => getStorage(inject(PLATFORM_ID)), 64 | }); 65 | -------------------------------------------------------------------------------- /src/app/core/services/analytics.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | import { httpResource, type HttpResourceRef } from '@angular/common/http'; 3 | import { getEndpoints } from '~core/constants/endpoints.constants'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class AnalyticsService { 9 | private readonly endpoints = getEndpoints(); 10 | 11 | getRealtimeUsersResource(): HttpResourceRef<{ activeUsers: number }> { 12 | return httpResource<{ activeUsers: number }>(this.endpoints.analytics.v1.realtimeUsers, { 13 | defaultValue: { activeUsers: 1 }, 14 | }); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/app/core/services/language.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable, LOCALE_ID } from '@angular/core'; 2 | import { Router } from '@angular/router'; 3 | import { Language } from '~core/enums/language.enum'; 4 | import { Locale } from '~core/enums/locale.enum'; 5 | import { DEFAULT_LOCALE } from '~core/constants/language.constants'; 6 | 7 | @Injectable({ 8 | providedIn: 'root', 9 | }) 10 | export class LanguageService { 11 | private readonly localeId = inject(LOCALE_ID); 12 | private readonly router = inject(Router); 13 | 14 | convertLocaleToAcceptLanguage(): Language { 15 | if (this.localeId === (Locale.ES as string)) { 16 | return Language.ES_ES; 17 | } 18 | return Language.EN_US; 19 | } 20 | 21 | navigateWithUserLanguage(language: Language, pathToRedirect: string) { 22 | if (this.doesLocaleMatchLanguage(language)) { 23 | void this.router.navigate([pathToRedirect]); 24 | } else { 25 | const localeToRedirect = this.getLocaleFromLanguage(language); 26 | window.location.href = 27 | localeToRedirect === DEFAULT_LOCALE 28 | ? pathToRedirect 29 | : `/${localeToRedirect}${pathToRedirect}`; 30 | } 31 | } 32 | 33 | private doesLocaleMatchLanguage(language: Language) { 34 | if (this.localeId === (Locale.ES as string)) { 35 | return language === Language.ES_ES; 36 | } 37 | return language === Language.EN_US; 38 | } 39 | 40 | private getLocaleFromLanguage(language: Language): Locale { 41 | if (language === Language.ES_ES) { 42 | return Locale.ES; 43 | } 44 | return DEFAULT_LOCALE; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/app/core/services/storage/cookie-consent.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { LOCAL_STORAGE } from '~core/providers/local-storage'; 3 | 4 | declare const window: Window & 5 | // eslint-disable-next-line @typescript-eslint/max-params 6 | typeof globalThis & { gtag?: (a: string, b: string, o: object) => void }; 7 | 8 | const CONSENT_COOKIE_KEY = 'isCookiesConsentAccepted'; 9 | const CONSENT_COOKIE_VALUE = 'true'; 10 | 11 | export enum ConsentState { 12 | DENIED = 'denied', 13 | GRANTED = 'granted', 14 | } 15 | 16 | @Injectable({ 17 | providedIn: 'root', 18 | }) 19 | export class CookieConsentService { 20 | private readonly localStorage: Storage | null = inject(LOCAL_STORAGE); 21 | 22 | setCookieConsent(state: ConsentState): boolean { 23 | if (!this.setConsentInLocalStorage()) { 24 | return false; 25 | } 26 | return this.updateGtagConsent(state); 27 | } 28 | 29 | getCookieState(): boolean { 30 | try { 31 | return this.localStorage?.getItem(CONSENT_COOKIE_KEY) === CONSENT_COOKIE_VALUE; 32 | } catch { 33 | return false; 34 | } 35 | } 36 | 37 | private setConsentInLocalStorage(): boolean { 38 | try { 39 | this.localStorage?.setItem(CONSENT_COOKIE_KEY, CONSENT_COOKIE_VALUE); 40 | return true; 41 | } catch { 42 | return false; 43 | } 44 | } 45 | 46 | private updateGtagConsent(state: ConsentState): boolean { 47 | try { 48 | if (window.gtag) { 49 | const consentOptions = { 50 | /* eslint-disable camelcase*/ 51 | ad_user_data: state, 52 | ad_personalization: state, 53 | ad_storage: state, 54 | analytics_storage: state, 55 | }; 56 | 57 | if (state === ConsentState.DENIED) { 58 | window.gtag('consent', 'default', { 59 | ...consentOptions, 60 | wait_for_update: 500, 61 | /* eslint-enable camelcase*/ 62 | }); 63 | } else { 64 | window.gtag('consent', 'update', { 65 | ...consentOptions, 66 | }); 67 | } 68 | } 69 | return true; 70 | } catch { 71 | return false; 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/app/core/services/storage/file.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpClient } from '@angular/common/http'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import type { Observable } from 'rxjs'; 4 | 5 | @Injectable({ 6 | providedIn: 'root', 7 | }) 8 | export class FileService { 9 | private readonly httpClient = inject(HttpClient); 10 | 11 | getFileAsText(fileUrl: string): Observable { 12 | return this.httpClient.get(fileUrl, { responseType: 'text' }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/app/core/services/ui/alert.store.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, signal } from '@angular/core'; 2 | import type { Alert } from '~core/constants/alerts.constants'; 3 | import { AlertType } from '~core/constants/alerts.constants'; 4 | 5 | @Injectable({ providedIn: 'root' }) 6 | export class AlertStore { 7 | private readonly _alerts = signal([]); 8 | 9 | readonly alerts = this._alerts.asReadonly(); 10 | 11 | createSuccessAlert(message: string) { 12 | this.createAlert({ 13 | id: this.generateAlertId(), 14 | message, 15 | type: AlertType.SUCCESS, 16 | duration: 7000, 17 | hasCountdown: true, 18 | }); 19 | } 20 | 21 | createErrorAlert(message: string) { 22 | this.createAlert({ id: this.generateAlertId(), message, type: AlertType.ERROR }); 23 | } 24 | 25 | removeAlert(alertToRemove: Alert) { 26 | this._alerts.update((alerts) => alerts.filter((alert) => alert !== alertToRemove)); 27 | } 28 | 29 | private createAlert(alert: Alert) { 30 | this._alerts.update((alerts) => [...alerts, alert]); 31 | } 32 | 33 | private generateAlertId(): string { 34 | return Math.random().toString(36).slice(2, 9) + Date.now().toString(36); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/core/services/ui/header.service.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { inject, Injectable } from '@angular/core'; 3 | import type { Environment } from '~core/tokens/environment.token'; 4 | import { ENVIRONMENT } from '~core/tokens/environment.token'; 5 | 6 | @Injectable({ 7 | providedIn: 'root', 8 | }) 9 | export class HeaderService { 10 | private readonly environment = inject(ENVIRONMENT); 11 | private readonly document = inject(DOCUMENT); 12 | 13 | setCanonical(absolutePath: string): void { 14 | const [pathWithoutFragment] = HeaderService.normalizePath(absolutePath).split('#'), 15 | fullPath = `${this.environment.domain}/${pathWithoutFragment}`; 16 | this.document.querySelector('link[rel=canonical]')?.setAttribute('href', fullPath); 17 | } 18 | 19 | private static normalizePath(path: string): string { 20 | if (path.startsWith('/')) { 21 | return path.slice(1); 22 | } 23 | return path; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/app/core/services/ui/theme-manager.service.ts: -------------------------------------------------------------------------------- 1 | import { DOCUMENT } from '@angular/common'; 2 | import { inject, Injectable, signal } from '@angular/core'; 3 | import { LOCAL_STORAGE } from '~core/providers/local-storage'; 4 | 5 | // Keep these constants in sync with the code in index.html 6 | const DARK_THEME_CLASS_NAME = 'theme-dark--mode', 7 | LIGHT_THEME_CLASS_NAME = 'theme-light--mode', 8 | THEME_SELECTED_LOCAL_STORAGE_KEY = 'theme'; 9 | 10 | export enum Theme { 11 | DARK = 'dark', 12 | LIGHT = 'light', 13 | } 14 | 15 | @Injectable({ 16 | providedIn: 'root', 17 | }) 18 | export class ThemeManagerService { 19 | private readonly document = inject(DOCUMENT); 20 | private readonly localStorage: Storage | null = inject(LOCAL_STORAGE); 21 | private readonly _themeSelected = signal(Theme.DARK); 22 | 23 | readonly themeSelected = this._themeSelected.asReadonly(); 24 | 25 | constructor() { 26 | const themeFromLocalStorage = this.localStorage?.getItem( 27 | THEME_SELECTED_LOCAL_STORAGE_KEY, 28 | ) as Theme | null; 29 | if (themeFromLocalStorage) { 30 | this.setTheme(themeFromLocalStorage); 31 | } 32 | } 33 | 34 | setTheme(theme: Theme): void { 35 | this._themeSelected.set(theme); 36 | this.localStorage?.setItem(THEME_SELECTED_LOCAL_STORAGE_KEY, this.themeSelected()); 37 | this.setBodyClasses(); 38 | } 39 | 40 | private setBodyClasses(): void { 41 | const documentClassList = this.document.documentElement.classList; 42 | if (this.themeSelected() === Theme.DARK) { 43 | documentClassList.add(DARK_THEME_CLASS_NAME); 44 | documentClassList.remove(LIGHT_THEME_CLASS_NAME); 45 | } else { 46 | documentClassList.add(LIGHT_THEME_CLASS_NAME); 47 | documentClassList.remove(DARK_THEME_CLASS_NAME); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/app/core/tokens/environment.token.ts: -------------------------------------------------------------------------------- 1 | import { InjectionToken } from '@angular/core'; 2 | 3 | export type Environment = { 4 | apiBaseUrl: string; 5 | domain: boolean; 6 | }; 7 | 8 | export const ENVIRONMENT = new InjectionToken('Environment Configuration'); 9 | -------------------------------------------------------------------------------- /src/app/core/types/api-response.types.ts: -------------------------------------------------------------------------------- 1 | export type ApiResponse = { 2 | ok: boolean; 3 | data: T; 4 | }; 5 | 6 | export type ApiErrorResponse = { 7 | error: { 8 | internalCode?: number; 9 | message?: string; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/core/validators/email.validator.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; 2 | 3 | export function emailValidator(): ValidatorFn { 4 | return (control: AbstractControl): ValidationErrors | null => { 5 | const { value } = control; 6 | if (!value) { 7 | return null; 8 | } 9 | 10 | const emailRegex = /^[^\s@]+@[^\s@][^\s.@]*\.[^\s@]+$/u; 11 | return emailRegex.test(value) ? null : { email: value }; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /src/app/core/validators/password.validator.ts: -------------------------------------------------------------------------------- 1 | import type { AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms'; 2 | 3 | export function passwordValidator(): ValidatorFn { 4 | const validators = [ 5 | (value: string) => /[A-Z]/u.test(value), // Has uppercase 6 | (value: string) => /[a-z]/u.test(value), // Has lowercase 7 | (value: string) => /[0-9]/u.test(value), // Has numeric 8 | (value: string) => value.length >= 8, // Is valid length 9 | ]; 10 | 11 | return (control: AbstractControl): ValidationErrors | null => { 12 | const value = control.value as string; 13 | if (!value) { 14 | return null; 15 | } 16 | return validators.every((function_) => function_(value)) ? null : { passwordStrength: true }; 17 | }; 18 | } 19 | -------------------------------------------------------------------------------- /src/app/core/validators/pokemon.validator.ts: -------------------------------------------------------------------------------- 1 | import type { Observable } from 'rxjs'; 2 | import { catchError, map, of } from 'rxjs'; 3 | import { inject, Injectable, signal } from '@angular/core'; 4 | import type { AbstractControl, AsyncValidator, ValidationErrors } from '@angular/forms'; 5 | import { PokemonService } from '~features/pokemon/services/pokemon.service'; 6 | 7 | @Injectable({ providedIn: 'root' }) 8 | export class PokemonValidator implements AsyncValidator { 9 | private readonly pokemonService = inject(PokemonService); 10 | private readonly pokemonName = signal(''); 11 | 12 | readonly pokemonId = signal(-1); 13 | readonly isPokemonValidating = signal(false); 14 | 15 | validate(control: AbstractControl): Observable { 16 | const pokemonName = (control.value ?? '').toLowerCase().trim(); 17 | 18 | if (!pokemonName) { 19 | this.isPokemonValidating.set(false); 20 | return of(null); 21 | } 22 | 23 | this.pokemonName.set(pokemonName.toLowerCase()); 24 | this.isPokemonValidating.set(true); 25 | return this.pokemonService.getPokemon(pokemonName.toLowerCase()).pipe( 26 | map((pokemon) => { 27 | this.isPokemonValidating.set(false); 28 | this.pokemonId.set(pokemon.id); 29 | return null; 30 | }), 31 | catchError(() => { 32 | this.isPokemonValidating.set(false); 33 | return of({ pokemonName: true }); 34 | }), 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/app/features/authentication/authentication.routes.ts: -------------------------------------------------------------------------------- 1 | import { LogInComponent } from '~features/authentication/pages/log-in/log-in.component'; 2 | import { RegisterComponent } from '~features/authentication/pages/register/register.component'; 3 | import { noAuthenticationGuard } from '~core/guards/no-authentication.guard'; 4 | import { AUTHENTICATION_PATHS, ROOT_PATHS } from '~core/constants/paths.constants'; 5 | import { authenticationGuard } from '~core/guards/authentication.guard'; 6 | import { MyAccountComponent } from '~features/authentication/pages/my-account/my-account.component'; 7 | 8 | export const AUTHENTICATION_ROUTES = [ 9 | { 10 | path: AUTHENTICATION_PATHS.logIn, 11 | component: LogInComponent, 12 | canActivate: [noAuthenticationGuard], 13 | }, 14 | { 15 | path: AUTHENTICATION_PATHS.register, 16 | component: RegisterComponent, 17 | canActivate: [noAuthenticationGuard], 18 | }, 19 | { 20 | path: AUTHENTICATION_PATHS.myAccount, 21 | component: MyAccountComponent, 22 | canActivate: [authenticationGuard], 23 | }, 24 | { path: '**', redirectTo: ROOT_PATHS.error404 }, 25 | ]; 26 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/log-in/log-in-form.types.ts: -------------------------------------------------------------------------------- 1 | import type { FormControl, FormGroup } from '@angular/forms'; 2 | 3 | export type LogInFormGroup = FormGroup<{ 4 | email: FormControl; 5 | password: FormControl; 6 | }>; 7 | 8 | export type LogInFormState = { 9 | isLoading: boolean; 10 | isSubmitted: boolean; 11 | }; 12 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/log-in/log-in.component.html: -------------------------------------------------------------------------------- 1 |
2 |

¡Welcome back!

3 | 72 |
73 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/log-in/log-in.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | @use 'components/pages'; 3 | 4 | $login-form-max-width: 400px; 5 | 6 | :host { 7 | @include pages.read-page; 8 | 9 | text-align: center; 10 | 11 | .login__form { 12 | max-width: $login-form-max-width; 13 | 14 | .form-footer__paragraph { 15 | padding-block-end: 1rem; 16 | } 17 | 18 | .login__image-container { 19 | position: relative; 20 | 21 | .login__image { 22 | position: absolute; 23 | right: 7px; 24 | bottom: -52px; 25 | width: 55px; 26 | height: auto; 27 | 28 | @include mq.for-tablet-up { 29 | right: -6px; 30 | bottom: -65px; 31 | width: 75px; 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/log-in/log-in.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | CUSTOM_ELEMENTS_SCHEMA, 5 | DestroyRef, 6 | inject, 7 | signal, 8 | } from '@angular/core'; 9 | import { FormBuilder, FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; 10 | import { RouterModule } from '@angular/router'; 11 | import { NgOptimizedImage } from '@angular/common'; 12 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 13 | import { catchError, EMPTY, finalize } from 'rxjs'; 14 | import { emailValidator } from '~core/validators/email.validator'; 15 | import { AUTH_URLS, ROOT_URLS } from '~core/constants/urls.constants'; 16 | import { passwordValidator } from '~core/validators/password.validator'; 17 | import { SlInputIconFocusDirective } from '~core/directives/sl-input-icon-focus.directive'; 18 | import { LowercaseDirective } from '~core/directives/lowercase.directive'; 19 | import { TrimDirective } from '~core/directives/trim.directive'; 20 | import type { ApiErrorResponse } from '~core/types/api-response.types'; 21 | import { API_ERROR_CODES } from '~core/constants/api-error-codes.constants'; 22 | import { AlertStore } from '~core/services/ui/alert.store'; 23 | import { LanguageService } from '~core/services/language.service'; 24 | import { AuthenticationService } from '../../services/authentication.service'; 25 | import type { User } from '~features/authentication/types/user.type'; 26 | import type { 27 | LogInFormGroup, 28 | LogInFormState, 29 | } from '~features/authentication/pages/log-in/log-in-form.types'; 30 | import { translations } from '../../../../../locale/translations'; 31 | 32 | import '@shoelace-style/shoelace/dist/components/button/button.js'; 33 | import '@shoelace-style/shoelace/dist/components/input/input.js'; 34 | import '@shoelace-style/shoelace/dist/components/icon/icon.js'; 35 | 36 | @Component({ 37 | selector: 'app-log-in', 38 | templateUrl: './log-in.component.html', 39 | styleUrl: './log-in.component.scss', 40 | changeDetection: ChangeDetectionStrategy.OnPush, 41 | imports: [ 42 | ReactiveFormsModule, 43 | RouterModule, 44 | SlInputIconFocusDirective, 45 | NgOptimizedImage, 46 | LowercaseDirective, 47 | TrimDirective, 48 | ], 49 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 50 | }) 51 | export class LogInComponent { 52 | private readonly alertStore = inject(AlertStore); 53 | private readonly formBuilder = inject(FormBuilder); 54 | private readonly authService = inject(AuthenticationService); 55 | private readonly languageService = inject(LanguageService); 56 | private readonly destroyRef = inject(DestroyRef); 57 | 58 | readonly translations = translations; 59 | readonly authUrls = AUTH_URLS; 60 | readonly logInForm = this.createLoginForm(); 61 | readonly formControls = { 62 | email: this.logInForm.get('email') as FormControl, 63 | password: this.logInForm.get('password') as FormControl, 64 | }; 65 | readonly formState = signal({ 66 | isLoading: false, 67 | isSubmitted: false, 68 | }); 69 | 70 | sendForm(): void { 71 | this.updateFormState({ isSubmitted: true }); 72 | 73 | if (this.logInForm.invalid) { 74 | this.logInForm.markAllAsTouched(); 75 | return; 76 | } 77 | 78 | this.updateFormState({ isLoading: true }); 79 | this.authService 80 | .logIn(this.logInForm.getRawValue()) 81 | .pipe( 82 | takeUntilDestroyed(this.destroyRef), 83 | finalize(() => { 84 | this.updateFormState({ isLoading: false }); 85 | }), 86 | catchError((error: ApiErrorResponse) => { 87 | this.handleLoginError(error); 88 | return EMPTY; 89 | }), 90 | ) 91 | .subscribe({ 92 | next: (user: User) => { 93 | this.languageService.navigateWithUserLanguage(user.language, ROOT_URLS.myPokedex); 94 | }, 95 | }); 96 | } 97 | 98 | private createLoginForm(): LogInFormGroup { 99 | return this.formBuilder.group({ 100 | email: new FormControl('', { 101 | validators: [Validators.required, Validators.minLength(4), emailValidator()], 102 | nonNullable: true, 103 | }), 104 | password: new FormControl('', { 105 | validators: [Validators.required, Validators.minLength(6), passwordValidator()], 106 | nonNullable: true, 107 | }), 108 | }); 109 | } 110 | 111 | private handleLoginError(response: ApiErrorResponse): void { 112 | const errorMessage = 113 | response.error.internalCode === API_ERROR_CODES.INVALID_CREDENTIALS_CODE 114 | ? translations.loginCredentialsError 115 | : translations.genericErrorAlert; 116 | this.alertStore.createErrorAlert(errorMessage); 117 | } 118 | 119 | private updateFormState(updates: Partial): void { 120 | this.formState.update((state) => ({ ...state, ...updates })); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/my-account/my-account.component.html: -------------------------------------------------------------------------------- 1 |
2 |

My account

3 |
8 |
9 | 10 |
11 |
12 | 26 | 27 | 28 |
29 |
30 | 39 | 40 | 41 |
42 |
43 | 52 | English (US) 53 | Español (España) 54 | 55 |
56 |
57 |

Your favourite pokemon is:

58 | @if (pokemonImage()) { 59 |
60 | 61 |
62 | } 63 |
64 |
65 | 72 | Save 73 | 74 |
75 |
76 |
77 | ash standing 86 |
87 |
88 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/my-account/my-account.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | @use 'components/pages'; 3 | 4 | $update-user-form-max-width: 400px; 5 | $decorative-image-width: 200px; 6 | 7 | :host { 8 | @include pages.read-page; 9 | 10 | text-align: center; 11 | 12 | .update-user__form { 13 | position: relative; 14 | max-width: $update-user-form-max-width; 15 | padding: var(--spacing-r-4xl) var(--spacing-r-xl); 16 | margin-block-end: var(--spacing-r-4xl); 17 | 18 | @include mq.for-tablet-up { 19 | padding: var(--spacing-r-4xl) var(--spacing-r-xl); 20 | } 21 | 22 | .theme-button__container { 23 | position: absolute; 24 | top: var(--spacing-r-lg); 25 | right: var(--spacing-r-lg); 26 | } 27 | 28 | .form-control__container:last-of-type { 29 | margin: 0; 30 | } 31 | 32 | .favourite-pokemon__container { 33 | display: flex; 34 | flex-direction: column; 35 | align-items: center; 36 | 37 | .favourite-pokemon__image-container { 38 | width: 20%; 39 | height: auto; 40 | 41 | @include mq.for-tablet-up { 42 | width: 15%; 43 | } 44 | } 45 | } 46 | } 47 | 48 | .decorative__container { 49 | position: relative; 50 | 51 | .decorative__image { 52 | width: $decorative-image-width; 53 | height: auto; 54 | 55 | @include mq.for-tablet-landscape-up { 56 | position: absolute; 57 | right: -55px; 58 | bottom: 26px; 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/register/register-form.types.ts: -------------------------------------------------------------------------------- 1 | import type { FormControl, FormGroup } from '@angular/forms'; 2 | import type { WritableSignal } from '@angular/core'; 3 | 4 | export type RegisterFormGroup = FormGroup<{ 5 | name: FormControl; 6 | email: FormControl; 7 | password: FormControl; 8 | confirmPassword: FormControl; 9 | favouritePokemonId: FormControl; 10 | terms: FormControl; 11 | }>; 12 | 13 | export type RegisterFormValue = { 14 | name: string; 15 | email: string; 16 | password: string; 17 | confirmPassword: string; 18 | favouritePokemonId: number; 19 | terms: boolean; 20 | }; 21 | 22 | export type RegisterFormState = { 23 | isLoading: boolean; 24 | isSubmitted: boolean; 25 | isRegistrationCompleted: boolean; 26 | passwordsMatch: boolean; 27 | isPokemonValidating: WritableSignal; 28 | }; 29 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/register/register.component.html: -------------------------------------------------------------------------------- 1 |
2 |

¡Register and complete your Pokédex!

3 |
4 | pokemon haunter 12 |
13 | 27 | 28 | 29 |
30 |
31 | 46 | 47 | 48 |
49 |
50 | 65 | 66 | 67 |
68 |
69 | 84 | 85 | 86 |
87 |
88 | 103 | 104 | @if (formState().isPokemonValidating()) { 105 | pokeball 114 | } 115 | 116 |
117 |
118 | 125 | I’ve caught 'em all (the terms and privacy policy) and I’m cool with them! 126 | 127 |
128 |
129 | 138 | Create account 139 | 140 |
141 | 148 |
149 |
150 | -------------------------------------------------------------------------------- /src/app/features/authentication/pages/register/register.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | @use 'components/pages'; 3 | 4 | $register-form-max-width: 400px; 5 | 6 | :host { 7 | @include pages.read-page; 8 | 9 | text-align: center; 10 | 11 | .register__form { 12 | position: relative; 13 | max-width: $register-form-max-width; 14 | padding: var(--spacing-r-7xl) var(--spacing-r-xl); 15 | margin-block-start: var(--spacing-r-6xl); 16 | 17 | @include mq.for-tablet-up { 18 | padding: var(--spacing-r-8xl) var(--spacing-r-xl); 19 | margin-block-start: var(--spacing-r-7xl); 20 | } 21 | 22 | .register-form__image { 23 | position: absolute; 24 | top: -37px; 25 | left: -19px; 26 | width: 82px; 27 | height: auto; 28 | opacity: 0; 29 | animation: fade-in 5s forwards; 30 | 31 | &:hover { 32 | opacity: 1; 33 | animation: fade-out 1s forwards; 34 | } 35 | 36 | @include mq.for-tablet-up { 37 | top: -42px; 38 | left: -40px; 39 | width: 95px; 40 | } 41 | } 42 | 43 | .button--primary.pokemon-appear { 44 | border-radius: var(--border-radius-max); 45 | animation: flash 0.7s infinite; 46 | } 47 | } 48 | } 49 | 50 | @keyframes fade-in { 51 | to { 52 | opacity: 1; 53 | } 54 | } 55 | 56 | @keyframes fade-out { 57 | to { 58 | opacity: 0; 59 | } 60 | } 61 | 62 | @keyframes flash { 63 | 0%, 64 | 100% { 65 | background-color: white; 66 | } 67 | 68 | 50% { 69 | background-color: black; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/app/features/authentication/services/authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable, signal, linkedSignal } from '@angular/core'; 2 | import { LOCAL_STORAGE } from '~core/providers/local-storage'; 3 | import { HttpClient } from '@angular/common/http'; 4 | import type { Observable } from 'rxjs'; 5 | import { map } from 'rxjs'; 6 | import type { LoginRequest } from '~features/authentication/types/login-request.type'; 7 | import type { LoginResponse } from '~features/authentication/types/login-response.type'; 8 | import type { 9 | RefreshTokenResponse, 10 | RefreshTokenResponseData, 11 | } from '~features/authentication/types/refresh-token.response.type'; 12 | import type { 13 | RegisterResponse, 14 | RegisterResponseData, 15 | } from '~features/authentication/types/register-response.type'; 16 | import { LanguageService } from '~core/services/language.service'; 17 | import type { User } from '~features/authentication/types/user.type'; 18 | import { clearCache } from '~core/interceptors/caching.interceptor'; 19 | import { getEndpoints } from '~core/constants/endpoints.constants'; 20 | import type { RegisterFormValue } from '~features/authentication/pages/register/register-form.types'; 21 | 22 | export const ACCESS_TOKEN_KEY = 'access-token'; 23 | export const REFRESH_TOKEN_KEY = 'refresh-token'; 24 | 25 | @Injectable({ 26 | providedIn: 'root', 27 | }) 28 | export class AuthenticationService { 29 | private readonly endpoints = getEndpoints(); 30 | private readonly storageService = inject(LOCAL_STORAGE); 31 | private readonly httpClient = inject(HttpClient); 32 | private readonly languageService = inject(LanguageService); 33 | 34 | private readonly authTokens = signal<{ accessToken?: string; refreshToken?: string }>({ 35 | accessToken: this.storageService?.getItem(ACCESS_TOKEN_KEY) ?? undefined, 36 | refreshToken: this.storageService?.getItem(REFRESH_TOKEN_KEY) ?? undefined 37 | }); 38 | 39 | readonly authState = linkedSignal({ 40 | source: this.authTokens, 41 | computation: (tokens) => ({ 42 | isLoggedIn: !!tokens.accessToken, 43 | hasRefreshToken: !!tokens.refreshToken, 44 | accessToken: tokens.accessToken, 45 | refreshToken: tokens.refreshToken 46 | }) 47 | }); 48 | 49 | register(registerRequest: RegisterFormValue): Observable { 50 | return this.httpClient 51 | .post( 52 | this.endpoints.auth.v1.authentication, 53 | { 54 | email: registerRequest.email.toLowerCase(), 55 | password: registerRequest.password, 56 | name: registerRequest.name, 57 | favouritePokemonId: registerRequest.favouritePokemonId, 58 | terms: registerRequest.terms, 59 | }, 60 | { 61 | headers: { 62 | 'Accept-Language': this.languageService.convertLocaleToAcceptLanguage(), 63 | }, 64 | }, 65 | ) 66 | .pipe( 67 | map((response: RegisterResponse) => { 68 | const { data } = response; 69 | this.saveTokens(data); 70 | return data; 71 | }), 72 | ); 73 | } 74 | 75 | logIn(loginRequest: LoginRequest): Observable { 76 | return this.httpClient 77 | .post(this.endpoints.auth.v1.login, { 78 | email: loginRequest.email.toLowerCase(), 79 | password: loginRequest.password, 80 | }) 81 | .pipe( 82 | map((response: LoginResponse) => { 83 | const { data } = response; 84 | this.saveTokens(data); 85 | return data.user; 86 | }), 87 | ); 88 | } 89 | 90 | refreshToken(): Observable { 91 | return this.httpClient 92 | .post(this.endpoints.auth.v1.refreshToken, { 93 | refreshToken: this.storageService?.getItem(REFRESH_TOKEN_KEY), 94 | }) 95 | .pipe( 96 | map((response: RefreshTokenResponse) => { 97 | const { data } = response; 98 | this.saveTokens(data); 99 | return data; 100 | }), 101 | ); 102 | } 103 | 104 | logOut() { 105 | clearCache(); 106 | this.removeTokens(); 107 | } 108 | 109 | private saveTokens(data: { accessToken: string; refreshToken?: string }) { 110 | this.storageService?.setItem(ACCESS_TOKEN_KEY, data.accessToken); 111 | if (data.refreshToken) { 112 | this.storageService?.setItem(REFRESH_TOKEN_KEY, data.refreshToken); 113 | } 114 | this.authTokens.set({ 115 | accessToken: data.accessToken, 116 | refreshToken: data.refreshToken 117 | }); 118 | } 119 | 120 | private removeTokens() { 121 | this.storageService?.removeItem(ACCESS_TOKEN_KEY); 122 | this.storageService?.removeItem(REFRESH_TOKEN_KEY); 123 | this.authTokens.set({}); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/app/features/authentication/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import { HttpClient, HttpContext } from '@angular/common/http'; 3 | import type { Observable } from 'rxjs'; 4 | import { map } from 'rxjs'; 5 | import { CACHING_ENABLED } from '~core/interceptors/caching.interceptor'; 6 | import type { GetMeResponse } from '~features/authentication/types/get-me-response.type'; 7 | import type { User } from '~features/authentication/types/user.type'; 8 | import type { UpdateUserRequest } from '~features/authentication/types/update-user-request.type'; 9 | import type { UpdateUserResponse } from '~features/authentication/types/update-user-response.type'; 10 | import type { CatchPokemonRequest } from '~features/authentication/types/catch-pokemon-request.type'; 11 | import type { CatchPokemonResponse } from '~features/authentication/types/catch-pokemon-response.type'; 12 | import { getEndpoints } from '~core/constants/endpoints.constants'; 13 | 14 | @Injectable({ 15 | providedIn: 'root', 16 | }) 17 | export class UserService { 18 | private readonly endpoints = getEndpoints(); 19 | private readonly httpClient = inject(HttpClient); 20 | 21 | getMe(options?: { cache: boolean }): Observable { 22 | const { cache = true } = options ?? {}; 23 | return this.httpClient 24 | .get(this.endpoints.user.v1.user, { 25 | context: new HttpContext().set(CACHING_ENABLED, cache), 26 | }) 27 | .pipe( 28 | map((response: GetMeResponse) => { 29 | const { data } = response; 30 | return data.user; 31 | }), 32 | ); 33 | } 34 | 35 | updateUser(updateUserRequest: UpdateUserRequest): Observable { 36 | return this.httpClient 37 | .patch(this.endpoints.user.v1.user, updateUserRequest) 38 | .pipe( 39 | map((response: UpdateUserResponse) => { 40 | const { data } = response; 41 | return data.user; 42 | }), 43 | ); 44 | } 45 | 46 | catchPokemon(catchPokemonRequest: CatchPokemonRequest): Observable { 47 | return this.httpClient 48 | .post(this.endpoints.user.v1.pokemonCatch, catchPokemonRequest) 49 | .pipe( 50 | map((response: CatchPokemonResponse) => { 51 | const { data } = response; 52 | return data.user; 53 | }), 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/catch-pokemon-request.type.ts: -------------------------------------------------------------------------------- 1 | export type CatchPokemonRequest = { 2 | pokemonId: number; 3 | }; 4 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/catch-pokemon-response.type.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '~features/authentication/types/user.type'; 2 | import type { ApiResponse } from '~core/types/api-response.types'; 3 | 4 | export type CatchPokemonResponseData = { 5 | user: User; 6 | }; 7 | 8 | export type CatchPokemonResponse = ApiResponse; 9 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/get-me-response.type.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '~features/authentication/types/user.type'; 2 | import type { ApiResponse } from '~core/types/api-response.types'; 3 | 4 | export type GetMeResponseData = { 5 | user: User; 6 | }; 7 | 8 | export type GetMeResponse = ApiResponse; 9 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/login-request.type.ts: -------------------------------------------------------------------------------- 1 | export type LoginRequest = { 2 | email: string; 3 | password: string; 4 | }; 5 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/login-response.type.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '~features/authentication/types/user.type'; 2 | import type { ApiResponse } from '~core/types/api-response.types'; 3 | 4 | export type LoginResponseData = { 5 | accessToken: string; 6 | refreshToken: string; 7 | user: User; 8 | }; 9 | 10 | export type LoginResponse = ApiResponse; 11 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/refresh-token.response.type.ts: -------------------------------------------------------------------------------- 1 | import type { ApiResponse } from '~core/types/api-response.types'; 2 | 3 | export type RefreshTokenResponseData = { 4 | accessToken: string; 5 | }; 6 | 7 | export type RefreshTokenResponse = ApiResponse; 8 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/register-request.type.ts: -------------------------------------------------------------------------------- 1 | export type RegisterRequest = { 2 | email: string; 3 | password: string; 4 | name: string; 5 | favouritePokemonId: number; 6 | terms: boolean; 7 | }; 8 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/register-response.type.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '~features/authentication/types/user.type'; 2 | import type { ApiResponse } from '~core/types/api-response.types'; 3 | 4 | export type RegisterResponseData = { 5 | accessToken: string; 6 | refreshToken: string; 7 | user: User; 8 | }; 9 | 10 | export type RegisterResponse = ApiResponse; 11 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/update-user-request.type.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from '~core/enums/language.enum'; 2 | 3 | export type UpdateUserRequest = { 4 | name?: string; 5 | language?: Language; 6 | }; 7 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/update-user-response.type.ts: -------------------------------------------------------------------------------- 1 | import type { User } from '~features/authentication/types/user.type'; 2 | import type { ApiResponse } from '~core/types/api-response.types'; 3 | 4 | export type UpdateUserResponseData = { 5 | user: User; 6 | }; 7 | 8 | export type UpdateUserResponse = ApiResponse; 9 | -------------------------------------------------------------------------------- /src/app/features/authentication/types/user.type.ts: -------------------------------------------------------------------------------- 1 | import type { Language } from '~core/enums/language.enum'; 2 | 3 | export type User = { 4 | id: string; 5 | createdAt: string; 6 | updatedAt: string; 7 | email: string; 8 | name: string; 9 | language: Language; 10 | favouritePokemonId: number; 11 | caughtPokemonIds: number[]; 12 | }; 13 | -------------------------------------------------------------------------------- /src/app/features/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 | 5 |

6 | Angular
7 | Example App 8 |

9 |
10 | 11 |

12 | This project is a modern, real-world application designed as a foundation for creating new 13 | Angular-based projects and also a practical resource for learning. 14 |

15 | 16 |

17 | It comes with features like internationalization, standalone components, a more logical folder 18 | structure, etc, making it a great starting point for building scalable Angular apps. 19 |

20 | 21 |

22 | Whether you’re looking for a boilerplate or a solid base project, this setup is designed to help 23 | you create clean, maintainable code with ease. 24 |

25 | 26 |

27 | This project leverages the PokeAPI to provide fun and practical examples, making it 28 | easier to understand key concepts in Angular development. By using data from the Pokémon 29 | universe, it offers a familiar and engaging way to showcase features like fetching data, 30 | handling API calls, and displaying dynamic content. 31 |

32 | 33 |
34 | ash and pikachu 42 |
43 | 44 |

Key features

45 | 46 |
47 | 48 | 49 |

Angular Signals

50 |
51 | 52 |

Unlock reactivity with Angular Signals.

53 |

54 | Signals provide a declarative way to manage state and reactivity in your application, 55 | simplifying data flow and improving performance. 56 |

57 |
58 | Learn more about Signals 59 |
60 | 61 | 62 |

Internationalization

63 |
64 | 65 |

Build apps for a global audience.

66 |

67 | Angular’s internationalization tools make it seamless to localize your app, handle 68 | translations, and format dates, numbers, and currencies for any locale. 69 |

70 |
71 | Explore Internationalization 72 |
73 | 74 | 75 |

Reactive Forms

76 |
77 | 78 |

Effortless form handling and validation.

79 |

80 | Reactive Forms empower you to create robust, dynamic forms with a model-driven approach, 81 | making validation, dynamic updates, and testing a breeze. 82 |

83 |
84 | Start with Reactive Forms 85 |
86 | 87 | 88 |

Animations

89 |
90 | 91 |

Bring your UI to life with Angular Animations.

92 |

93 | Create smooth transitions and engaging effects with Angular’s powerful animation API, 94 | making your application visually appealing and interactive. 95 |

96 |
97 | Discover Angular Animations 98 |
99 |
100 |
101 | pokemon charizard 109 |
110 |
111 | pokemon blastoise 119 |
120 |
121 |

Users seeing this page: {{ activeUsersResource.value().activeUsers }}

122 |
123 |
124 | -------------------------------------------------------------------------------- /src/app/features/home/home.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | @use 'components/pages'; 3 | 4 | $separator-image-width: 130px; 5 | $separator-margin: 0 10px 35px 0; 6 | 7 | :host { 8 | @include pages.read-page; 9 | 10 | .home__heading-kbd { 11 | margin: var(--spacing-r-md) 0; 12 | } 13 | 14 | .separator__container { 15 | text-align: center; 16 | border-bottom: 1px solid var(--septenary-contrast); 17 | 18 | img { 19 | width: $separator-image-width; 20 | height: auto; 21 | margin: $separator-margin; 22 | } 23 | } 24 | 25 | .cards__grid-container { 26 | display: grid; 27 | grid-template-columns: repeat(1, 1fr); 28 | grid-gap: var(--spacing-r-xxl); 29 | margin-block: var(--spacing-r-xl); 30 | margin-block-end: var(--spacing-r-5xl); 31 | 32 | @include mq.for-tablet-portrait-up { 33 | grid-template-columns: repeat(2, 1fr); 34 | } 35 | } 36 | 37 | .real-time__container { 38 | margin-block-start: var(--spacing-r-5xl); 39 | 40 | p { 41 | text-align: center; 42 | } 43 | } 44 | 45 | .decorative-image__container-1 { 46 | text-align: right; 47 | 48 | img { 49 | width: 230px; 50 | height: auto; 51 | } 52 | 53 | @media (width >= 1200px) { 54 | position: absolute; 55 | right: 10px; 56 | bottom: 80px; 57 | 58 | img { 59 | width: 200px; 60 | height: auto; 61 | } 62 | } 63 | 64 | @media (width >= 1300px) { 65 | img { 66 | width: 250px; 67 | } 68 | } 69 | 70 | @media (width >= 1330px) { 71 | right: 30px; 72 | 73 | img { 74 | width: 250px; 75 | } 76 | } 77 | 78 | @media (width >= 1490px) { 79 | img { 80 | width: 330px; 81 | } 82 | } 83 | } 84 | 85 | .decorative-image__container-2 { 86 | text-align: left; 87 | 88 | img { 89 | width: 200px; 90 | height: auto; 91 | } 92 | 93 | @media (width >= 1200px) { 94 | position: absolute; 95 | bottom: -300px; 96 | left: 10px; 97 | 98 | img { 99 | width: 180px; 100 | height: auto; 101 | } 102 | } 103 | 104 | @media (width >= 1300px) { 105 | left: 40px; 106 | 107 | img { 108 | width: 220px; 109 | } 110 | } 111 | 112 | @media (width >= 1330px) { 113 | img { 114 | width: 220px; 115 | } 116 | } 117 | 118 | @media (width >= 1490px) { 119 | left: 60px; 120 | 121 | img { 122 | width: 270px; 123 | } 124 | } 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/app/features/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, effect, inject } from '@angular/core'; 2 | import { NgOptimizedImage } from '@angular/common'; 3 | import { DecorativeHeaderComponent } from '~core/components/decorative-header/decorative-header.component'; 4 | import { CardComponent } from '~core/components/card/card.component'; 5 | import { interval } from 'rxjs'; 6 | import { AnalyticsService } from '~core/services/analytics.service'; 7 | 8 | @Component({ 9 | selector: 'app-home', 10 | templateUrl: './home.component.html', 11 | styleUrl: './home.component.scss', 12 | changeDetection: ChangeDetectionStrategy.OnPush, 13 | standalone: true, 14 | imports: [DecorativeHeaderComponent, NgOptimizedImage, CardComponent], 15 | }) 16 | export class HomeComponent { 17 | private readonly analyticsService = inject(AnalyticsService); 18 | readonly activeUsersResource = this.analyticsService.getRealtimeUsersResource(); 19 | 20 | constructor() { 21 | this.activeUsersResource.reload(); 22 | effect(() => { 23 | const sub = interval(5000).subscribe(() => { 24 | this.activeUsersResource.reload(); 25 | }); 26 | return () => { 27 | sub.unsubscribe(); 28 | }; 29 | }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/catch-animation/catch-animation.component.html: -------------------------------------------------------------------------------- 1 | @if (pokemonState() !== 'disappear') { 2 |
3 | 4 |
5 | } 6 | 7 | 8 | Pokeball 23 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/catch-animation/catch-animation.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | :host { 4 | .pokemon__container { 5 | position: absolute; 6 | right: 45px; 7 | bottom: -105px; 8 | 9 | @include mq.for-tablet-up { 10 | right: 130px; 11 | bottom: -170px; 12 | } 13 | } 14 | 15 | .pokeball__image { 16 | position: absolute; 17 | top: 161px; 18 | left: 96px; 19 | width: 20px; 20 | height: auto; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/catch-animation/catch-animation.component.ts: -------------------------------------------------------------------------------- 1 | import type { OnInit } from '@angular/core'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | effect, 6 | input, 7 | signal, 8 | type WritableSignal, 9 | } from '@angular/core'; 10 | import { NgOptimizedImage, NgStyle } from '@angular/common'; 11 | import { BattleEvent } from '~features/pokemon/components/pokedex/enums/pokedex-action.enum'; 12 | import { catchAnimations } from '~features/pokemon/components/catch-animation/catch.animations'; 13 | 14 | enum PokeballState { 15 | Idle = 'idle', 16 | Catching = 'catching', 17 | Falling = 'falling', 18 | Shaking = 'shaking', 19 | Shining = 'shining', 20 | } 21 | 22 | enum PokemonState { 23 | Idle = 'idle', 24 | Shining = 'shining', 25 | Disappear = 'disappear', 26 | } 27 | 28 | @Component({ 29 | selector: 'app-catch-animation', 30 | templateUrl: './catch-animation.component.html', 31 | styleUrl: './catch-animation.component.scss', 32 | changeDetection: ChangeDetectionStrategy.OnPush, 33 | animations: [catchAnimations], 34 | imports: [NgOptimizedImage, NgStyle], 35 | host: { 36 | '(window:resize)': 'loadAnimationPositions()', 37 | }, 38 | }) 39 | export class CatchAnimationComponent implements OnInit { 40 | readonly pokemonBattleEvent = input.required>(); 41 | readonly pokeballStartingPoint = signal(''); 42 | readonly pokeballPokemonXPoint = signal(''); 43 | readonly pokeballPokemonYPoint = signal(''); 44 | readonly pokeballGroundYPoint = signal(''); 45 | readonly pokeballState = signal(PokeballState.Idle); 46 | readonly pokemonState = signal(PokemonState.Idle); 47 | 48 | constructor() { 49 | effect(() => { 50 | const pokemonBattleEvent = this.pokemonBattleEvent(); 51 | if (pokemonBattleEvent() === BattleEvent.THROW_POKEBALL) { 52 | this.startCatchAnimation(); 53 | } 54 | if ( 55 | pokemonBattleEvent() === BattleEvent.POKEMON_LOADED || 56 | pokemonBattleEvent() === BattleEvent.RESET_BATTLE 57 | ) { 58 | this.pokeballState.set(PokeballState.Idle); 59 | this.pokemonState.set(PokemonState.Idle); 60 | } 61 | }); 62 | } 63 | 64 | ngOnInit() { 65 | this.loadAnimationPositions(); 66 | } 67 | 68 | startCatchAnimation() { 69 | this.pokeballState.set(PokeballState.Catching); 70 | 71 | setTimeout(() => { 72 | this.pokemonState.set(PokemonState.Shining); 73 | }, 500); 74 | setTimeout(() => { 75 | this.pokemonState.set(PokemonState.Disappear); 76 | }, 1500); 77 | setTimeout(() => { 78 | this.pokeballState.set(PokeballState.Falling); 79 | }, 1700); 80 | setTimeout(() => { 81 | this.pokeballState.set(PokeballState.Shaking); 82 | }, 3000); 83 | setTimeout(() => { 84 | this.pokeballState.set(PokeballState.Shining); 85 | this.pokemonBattleEvent().set(BattleEvent.CATCH_ANIMATION_ENDED); 86 | }, 6500); 87 | } 88 | 89 | loadAnimationPositions() { 90 | if (window.innerWidth <= 768) { 91 | this.setMobilePositions(); 92 | } else { 93 | this.setDesktopPositions(); 94 | } 95 | } 96 | 97 | private setMobilePositions() { 98 | this.pokeballStartingPoint.set('0px, -80px'); 99 | this.pokeballPokemonXPoint.set('105px'); 100 | this.pokeballPokemonYPoint.set('-140px'); 101 | this.pokeballGroundYPoint.set('-80px'); 102 | } 103 | 104 | private setDesktopPositions() { 105 | this.pokeballStartingPoint.set('80px, 15px'); 106 | this.pokeballPokemonXPoint.set('260px'); 107 | this.pokeballPokemonYPoint.set('-100px'); 108 | this.pokeballGroundYPoint.set('-10px'); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokedex/enums/pokedex-action.enum.ts: -------------------------------------------------------------------------------- 1 | export enum BattleEvent { 2 | RESET_BATTLE = 'RESET_BATTLE', 3 | POKEMON_LOADED = 'POKEMON_LOADED', 4 | THROW_POKEBALL = 'THROW_POKEBALL', 5 | CATCH_ANIMATION_ENDED = 'CATCH_ANIMATION_ENDED', 6 | } 7 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokedex/pokedex.component.html: -------------------------------------------------------------------------------- 1 |
2 |
8 |
9 |
10 |
11 |
12 |
13 | 14 |
15 |
16 | @if (pokemon()) { 17 |

{{pokemon()?.name | firstTitleCase}}

18 |

N.º: {{pokemon()?.order}}

19 |

Height: {{pokemon()?.height}} dm

20 |

Weight: {{pokemon()?.weight}} hg

21 | } 22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 | @if (userHasCaught()) { 35 |

36 | 🎯 Nice catch, {{user?.name}}! You’re one step closer to being a Pokémon Master—keep it 37 | up! 🌟 38 |

39 | } @else if (userHasPokemon()) { 40 |

Already got this one, keep going!

41 | } @else { 42 |

Still gotta catch 'em all, this one's missing from your Pokédex!

43 |
44 | 50 | POKEBALL 51 | 52 |
53 | } 54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokedex/pokedex.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | $pokedex-red: #cd112f; 4 | $pokedex-black: #000; 5 | $pokedex-dark-gray: #222; 6 | $pokedex-light-gray: #dedede; 7 | $pokedex-green: #52af5f; 8 | $pokedex-blue: #08cbf8; 9 | $pokedex-white: #fff; 10 | $pokedex-text-dark: #032d28; 11 | 12 | :host { 13 | .pokedex__container { 14 | position: relative; 15 | 16 | &.closed { 17 | .pokedex__flap-container { 18 | transform: rotateX(180deg) translate(0, -14px); 19 | } 20 | 21 | .pokedex__joystick-container { 22 | left: -100px; 23 | } 24 | 25 | .pokedex__pad-container::before { 26 | background: $pokedex-black; 27 | } 28 | } 29 | 30 | .pokedex__separator, 31 | .pokedex__bottom-part-container { 32 | background: $pokedex-red; 33 | border: var(--spacing-xs) solid $pokedex-black; 34 | } 35 | 36 | .pokedex__flap-container { 37 | position: absolute; 38 | width: 100%; 39 | height: 100%; 40 | transform: rotateX(360deg); 41 | transform-origin: 0 100%; 42 | transition: transform 1s; 43 | transform-style: preserve-3d; 44 | 45 | figure { 46 | position: absolute; 47 | display: block; 48 | width: 100%; 49 | height: 100%; 50 | margin: 0; 51 | backface-visibility: hidden; 52 | } 53 | 54 | .pokedex__flap-front, 55 | .pokedex__flap-back { 56 | background: $pokedex-red; 57 | border: var(--spacing-xs) solid $pokedex-black; 58 | } 59 | 60 | .pokedex__flap-front { 61 | border-bottom-right-radius: var(--spacing-3xl); 62 | border-bottom-left-radius: var(--spacing-3xl); 63 | transform: rotateX(180deg); 64 | } 65 | 66 | .pokedex__flap-back { 67 | border-top-left-radius: 10px; 68 | border-top-right-radius: var(--spacing-3xl); 69 | 70 | &::before { 71 | position: absolute; 72 | width: 50px; 73 | height: 100px; 74 | margin-top: 23px; 75 | margin-left: -52px; 76 | content: ' '; 77 | background: $pokedex-red; 78 | border: var(--spacing-sx) solid $pokedex-black; 79 | border-right-width: 0; 80 | border-top-left-radius: 50px; 81 | border-bottom-left-radius: 50px; 82 | } 83 | } 84 | } 85 | 86 | .pokedex__top-part-container { 87 | position: relative; 88 | z-index: 11; 89 | width: 230px; 90 | height: 150px; 91 | margin-left: 70px; 92 | cursor: pointer; 93 | perspective: 800px; 94 | } 95 | 96 | .pokedex__separator { 97 | position: relative; 98 | z-index: 10; 99 | width: 230px; 100 | height: 10px; 101 | margin-left: 70px; 102 | border-bottom-width: 0; 103 | } 104 | 105 | .pokedex__bottom-part-container { 106 | position: relative; 107 | z-index: 10; 108 | display: flex; 109 | flex-direction: row; 110 | width: 300px; 111 | height: 150px; 112 | border-top-left-radius: 75px; 113 | border-bottom-right-radius: var(--spacing-3xl); 114 | border-bottom-left-radius: 75px; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokedex/pokedex.component.ts: -------------------------------------------------------------------------------- 1 | import type { OnInit, WritableSignal } from '@angular/core'; 2 | import { 3 | ChangeDetectionStrategy, 4 | Component, 5 | CUSTOM_ELEMENTS_SCHEMA, 6 | DestroyRef, 7 | effect, 8 | inject, 9 | input, 10 | signal, 11 | } from '@angular/core'; 12 | import type { Pokemon } from '~features/pokemon/types/pokemon.type'; 13 | import { PokemonImageComponent } from '~features/pokemon/components/pokemon-image/pokemon-image.component'; 14 | import { FirstTitleCasePipe } from '~core/pipes/first-title-case.pipe'; 15 | import { UserService } from '~features/authentication/services/user.service'; 16 | import type { User } from '~features/authentication/types/user.type'; 17 | import { BattleEvent } from '~features/pokemon/components/pokedex/enums/pokedex-action.enum'; 18 | import { AlertStore } from '~core/services/ui/alert.store'; 19 | import { translations } from '../../../../../locale/translations'; 20 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 21 | 22 | @Component({ 23 | selector: 'app-pokedex', 24 | templateUrl: './pokedex.component.html', 25 | styleUrls: ['./pokedex.component.scss', './pokedex-pads.component.scss'], 26 | changeDetection: ChangeDetectionStrategy.OnPush, 27 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 28 | imports: [PokemonImageComponent, FirstTitleCasePipe], 29 | }) 30 | export class PokedexComponent implements OnInit { 31 | private readonly userService = inject(UserService); 32 | private readonly alertStore = inject(AlertStore); 33 | private readonly destroyRef = inject(DestroyRef); 34 | 35 | readonly pokemonBattleEvent = input.required>(); 36 | readonly pokemon = input(); 37 | readonly isPokedexClosed = signal(true); 38 | readonly pokemonImage = signal(''); 39 | readonly userHasCaught = signal(false); 40 | readonly userHasPokemon = signal(true); 41 | readonly isPokedexButtonDisabled = signal(false); 42 | 43 | translations = translations; 44 | user: User | undefined; 45 | updatedUser: User | undefined; 46 | 47 | constructor() { 48 | effect(() => { 49 | this.updatePokemonState(); 50 | this.handleBattleEvents(); 51 | }); 52 | } 53 | 54 | ngOnInit() { 55 | const pokemonValue = this.pokemon(); 56 | if (pokemonValue) { 57 | this.userService 58 | .getMe() 59 | .pipe(takeUntilDestroyed(this.destroyRef)) 60 | .subscribe({ 61 | next: (user: User) => { 62 | this.user = user; 63 | this.pokemonImage.set(pokemonValue.sprites.front_default); 64 | this.userHasPokemon.set(user.caughtPokemonIds.includes(pokemonValue.id)); 65 | setTimeout(() => { 66 | this.isPokedexClosed.set(false); 67 | }, 300); 68 | }, 69 | error: () => { 70 | this.alertStore.createErrorAlert(translations.genericErrorAlert); 71 | }, 72 | }); 73 | } 74 | } 75 | 76 | togglePokedex() { 77 | this.isPokedexClosed.set(!this.isPokedexClosed()); 78 | } 79 | 80 | notifyBattlefield() { 81 | this.isPokedexButtonDisabled.set(true); 82 | (this.pokemonBattleEvent() as unknown as WritableSignal).set( 83 | BattleEvent.THROW_POKEBALL, 84 | ); 85 | } 86 | 87 | catchPokemon() { 88 | this.userHasCaught.set(false); 89 | const pokemonId = this.pokemon()?.id; 90 | if (pokemonId) { 91 | this.userService 92 | .catchPokemon({ pokemonId }) 93 | .pipe(takeUntilDestroyed(this.destroyRef)) 94 | .subscribe({ 95 | next: (user) => { 96 | this.notifyBattlefield(); 97 | this.updatedUser = user; 98 | }, 99 | }); 100 | } 101 | } 102 | 103 | private updatePokemonState(): void { 104 | const pokemonValue = this.pokemon(); 105 | if (pokemonValue) { 106 | this.pokemonImage.set(pokemonValue.sprites.front_default); 107 | this.userHasPokemon.set(this.user?.caughtPokemonIds.includes(pokemonValue.id) ?? false); 108 | } 109 | } 110 | 111 | private handleBattleEvents(): void { 112 | const event = this.pokemonBattleEvent()(); 113 | switch (event as unknown as BattleEvent) { 114 | case BattleEvent.CATCH_ANIMATION_ENDED: { 115 | this.handleCatchAnimationEnded(); 116 | break; 117 | } 118 | case BattleEvent.RESET_BATTLE: { 119 | this.handleResetBattle(); 120 | break; 121 | } 122 | default: { 123 | break; 124 | } 125 | } 126 | } 127 | 128 | private handleCatchAnimationEnded(): void { 129 | if (this.updatedUser) { 130 | this.user = this.updatedUser; 131 | this.userHasCaught.set(true); 132 | } 133 | } 134 | 135 | private handleResetBattle(): void { 136 | this.userHasCaught.set(false); 137 | this.isPokedexButtonDisabled.set(false); 138 | const pokemonValue = this.pokemon(); 139 | const pokemonId = pokemonValue?.id; 140 | const caughtPokemonIds = this.user?.caughtPokemonIds ?? []; 141 | this.userHasPokemon.set(pokemonId ? caughtPokemonIds.includes(pokemonId) : true); 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-battlefield/pokemon-battlefield.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | Trainer frame 1 12 | Trainer frame 2 20 | Trainer frame 3 28 | Trainer frame 4 36 |
37 |
38 | 39 | 40 | 45 | 46 | 47 |
48 |
49 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-battlefield/pokemon-battlefield.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | @use 'components/pages'; 3 | 4 | $battle-terrain-container-border-color: #444; 5 | 6 | :host { 7 | .pokemon-battlefield__container { 8 | position: relative; 9 | width: 285px; 10 | height: 160px; 11 | background-image: url('https://res.cloudinary.com/ismaestro/image/upload/angularexampleapp/assets/images/battle-grass.png'); 12 | background-repeat: no-repeat; 13 | background-position: center; 14 | background-size: cover; 15 | border: 3px solid $battle-terrain-container-border-color; 16 | border-radius: var(--border-radius-lg); 17 | box-shadow: 0 var(--spacing-sm) var(--spacing-md) rgb(0 0 0 / 30%); 18 | 19 | @include mq.for-tablet-up { 20 | width: 512px; 21 | height: 288px; 22 | } 23 | 24 | .pokemon-battlefield__trainer-container { 25 | .pokemon-battlefield__trainer-image { 26 | position: absolute; 27 | bottom: 0; 28 | left: 70px; 29 | width: 45px; 30 | height: auto; 31 | opacity: 0; 32 | transition: opacity 1s ease-in-out; 33 | 34 | @include mq.for-tablet-up { 35 | bottom: 0; 36 | left: 150px; 37 | width: 65px; 38 | } 39 | } 40 | 41 | .trainer-1 { 42 | opacity: 1; 43 | } 44 | 45 | &.animate .trainer-1 { 46 | animation: 47 | trainer-throw-pokeball 0s 0.3s forwards, 48 | stay-visible 0s 0.9s forwards; 49 | } 50 | 51 | &.animate .trainer-2 { 52 | animation: trainer-throw-pokeball 0.3s 0.4s forwards; 53 | } 54 | 55 | &.animate .trainer-3 { 56 | animation: trainer-throw-pokeball 0.3s 0.4s forwards; 57 | } 58 | 59 | &.animate .trainer-4 { 60 | animation: trainer-throw-pokeball 0.3s 0.6s forwards; 61 | } 62 | } 63 | 64 | .pokemon-battlefield__pokemon-image-container { 65 | opacity: 0; 66 | transform: translateY(30px); 67 | transition: 68 | opacity 0.5s ease-in-out, 69 | transform 0.5s ease-in-out; 70 | 71 | &.loaded { 72 | opacity: 1; 73 | transform: translateY(-5px); 74 | } 75 | } 76 | } 77 | } 78 | 79 | @keyframes trainer-throw-pokeball { 80 | 0% { 81 | opacity: 1; 82 | } 83 | 84 | 50% { 85 | opacity: 1; 86 | } 87 | 88 | 100% { 89 | opacity: 0; 90 | } 91 | } 92 | 93 | @keyframes stay-visible { 94 | 0% { 95 | opacity: 1; 96 | } 97 | 98 | 100% { 99 | opacity: 1; 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-battlefield/pokemon-battlefield.component.ts: -------------------------------------------------------------------------------- 1 | import type { OnInit, WritableSignal } from '@angular/core'; 2 | import { ChangeDetectionStrategy, Component, effect, input, signal } from '@angular/core'; 3 | import type { Pokemon } from '~features/pokemon/types/pokemon.type'; 4 | import { PokemonImageComponent } from '~features/pokemon/components/pokemon-image/pokemon-image.component'; 5 | import { NgOptimizedImage } from '@angular/common'; 6 | import { BattleEvent } from '~features/pokemon/components/pokedex/enums/pokedex-action.enum'; 7 | import { CatchAnimationComponent } from '~features/pokemon/components/catch-animation/catch-animation.component'; 8 | 9 | @Component({ 10 | selector: 'app-pokemon-battlefield', 11 | templateUrl: './pokemon-battlefield.component.html', 12 | styleUrl: './pokemon-battlefield.component.scss', 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | imports: [PokemonImageComponent, CatchAnimationComponent, NgOptimizedImage], 15 | }) 16 | export class PokemonBattlefieldComponent implements OnInit { 17 | readonly pokemonBattleEvent = input.required>(); 18 | readonly pokemon = input(); 19 | readonly pokemonImage = signal(''); 20 | readonly startCatchAnimation = signal(false); 21 | readonly pokemonImageLoaded = signal(false); 22 | 23 | constructor() { 24 | effect(() => { 25 | this.updatePokemonImage(); 26 | this.handleThrowPokeballEvent(); 27 | this.handleResetBattleEvent(); 28 | }); 29 | } 30 | 31 | ngOnInit(): void { 32 | this.pokemonImage.set(this.pokemon()?.sprites.front_default ?? ''); 33 | } 34 | 35 | startPokemonInitialAnimation(loaded: boolean) { 36 | this.pokemonImageLoaded.set(loaded); 37 | } 38 | 39 | private updatePokemonImage(): void { 40 | const pokemonValue = this.pokemon(); 41 | if (pokemonValue) { 42 | this.pokemonImage.set(pokemonValue.sprites.front_default); 43 | } 44 | } 45 | 46 | private handleThrowPokeballEvent(): void { 47 | if ((this.pokemonBattleEvent()() as unknown as BattleEvent) === BattleEvent.THROW_POKEBALL) { 48 | this.startCatchAnimation.set(true); 49 | } 50 | } 51 | 52 | private handleResetBattleEvent(): void { 53 | if ((this.pokemonBattleEvent()() as unknown as BattleEvent) === BattleEvent.RESET_BATTLE) { 54 | this.startCatchAnimation.set(false); 55 | this.pokemonImageLoaded.set(false); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-card/pokemon-card.component.html: -------------------------------------------------------------------------------- 1 | @if (!loading()) { 2 | 3 | 4 |

{{ pokemon()?.name | firstTitleCase }}

5 |
6 | 7 |
8 | 9 | pokemon 10 |
11 |

N.º: {{pokemon()?.order}}

12 |

Height: {{pokemon()?.height}} dm

13 |

Weight: {{pokemon()?.weight}} hg

14 |
15 |
16 | } @else { 17 | 18 | 19 | 20 | 21 | 22 |
23 | 24 |
25 |

26 |

27 |

28 |
29 |
30 | } 31 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-card/pokemon-card.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | $pokemon-image-size: 100px; 4 | 5 | :host { 6 | .card__heading { 7 | margin-block: 0; 8 | font-size: var(--font-size-lg); 9 | } 10 | 11 | .pokemon__image-container { 12 | width: $pokemon-image-size; 13 | height: $pokemon-image-size; 14 | margin: 0 auto var(--spacing-r-xl); 15 | 16 | img { 17 | height: 100%; 18 | } 19 | } 20 | 21 | p { 22 | margin-bottom: var(--spacing-r-sm); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-card/pokemon-card.component.ts: -------------------------------------------------------------------------------- 1 | import type { OnInit } from '@angular/core'; 2 | import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, input } from '@angular/core'; 3 | import type { Pokemon } from '~features/pokemon/types/pokemon.type'; 4 | import { CardComponent } from '~core/components/card/card.component'; 5 | import { FirstTitleCasePipe } from '~core/pipes/first-title-case.pipe'; 6 | 7 | import '@shoelace-style/shoelace/dist/components/skeleton/skeleton.js'; 8 | 9 | @Component({ 10 | selector: 'app-pokemon-card', 11 | templateUrl: './pokemon-card.component.html', 12 | styleUrl: './pokemon-card.component.scss', 13 | changeDetection: ChangeDetectionStrategy.OnPush, 14 | imports: [CardComponent, FirstTitleCasePipe], 15 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 16 | }) 17 | export class PokemonCardComponent implements OnInit { 18 | readonly pokemon = input(); 19 | readonly loading = input(); 20 | 21 | pokemonImage: string | undefined; 22 | 23 | ngOnInit() { 24 | this.pokemonImage = this.pokemon()?.sprites.front_default; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-image/pokemon-image.component.html: -------------------------------------------------------------------------------- 1 | 2 | @if (croppedBase64Image()) { 3 | 4 | pokemon image 11 | } 12 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-image/pokemon-image.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | :host { 4 | .pokemon__image { 5 | height: auto; 6 | 7 | @include mq.for-phone-only { 8 | width: 100% !important; 9 | } 10 | } 11 | 12 | canvas { 13 | display: none; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-image/pokemon-image.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type AfterViewInit, 3 | ChangeDetectionStrategy, 4 | Component, 5 | effect, 6 | type ElementRef, 7 | inject, 8 | input, 9 | output, 10 | signal, 11 | type Signal, 12 | viewChild, 13 | } from '@angular/core'; 14 | import { NgStyle } from '@angular/common'; 15 | import { CropImageService } from '~features/pokemon/services/crop-image.service'; 16 | 17 | @Component({ 18 | selector: 'app-pokemon-image', 19 | templateUrl: './pokemon-image.component.html', 20 | styleUrl: './pokemon-image.component.scss', 21 | changeDetection: ChangeDetectionStrategy.OnPush, 22 | imports: [NgStyle], 23 | }) 24 | export class PokemonImageComponent implements AfterViewInit { 25 | private readonly cropImageService = inject(CropImageService); 26 | 27 | readonly loaded = output(); 28 | readonly canvas: Signal | undefined> = viewChild('canvas'); 29 | readonly image = input(); 30 | readonly imageWidth = input('100%'); 31 | readonly croppedBase64Image = signal(''); 32 | readonly croppedImageLoaded = signal(false); 33 | 34 | constructor() { 35 | effect(() => { 36 | this.resetState(); 37 | if (this.canvas()) { 38 | this.loadCroppedImage(); 39 | } 40 | }); 41 | } 42 | 43 | ngAfterViewInit() { 44 | this.loadCroppedImage(); 45 | } 46 | 47 | loadCroppedImage() { 48 | const canvasElement = this.canvas(); 49 | const imageValue = this.image(); 50 | if (canvasElement && imageValue) { 51 | void this.cropImageService 52 | .getCroppedImageURL(canvasElement.nativeElement, imageValue) 53 | .then((base64Image) => { 54 | this.croppedBase64Image.set(base64Image); 55 | this.loaded.emit(true); 56 | return base64Image; 57 | }); 58 | } 59 | } 60 | 61 | private resetState() { 62 | this.croppedBase64Image.set(''); 63 | this.croppedImageLoaded.set(false); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-search-input/pokemon-search-input.component.html: -------------------------------------------------------------------------------- 1 |
2 | 14 | 15 | 16 | 17 | @if (searchState().showButton) { 18 | 19 | pokeball 29 | 30 | } 31 |
32 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-search-input/pokemon-search-input.component.scss: -------------------------------------------------------------------------------- 1 | $pokemon-search-input-width: 230px; 2 | $pokemon-search-loading-image-size: 20px; 3 | 4 | :host { 5 | width: $pokemon-search-input-width; 6 | 7 | .search__container { 8 | display: flex; 9 | align-items: center; 10 | 11 | .search__input.has-action { 12 | margin-right: var(--spacing-lg); 13 | } 14 | 15 | .search__loading-image { 16 | width: $pokemon-search-loading-image-size; 17 | height: $pokemon-search-loading-image-size; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/app/features/pokemon/components/pokemon-search-input/pokemon-search-input.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, computed, 4 | CUSTOM_ELEMENTS_SCHEMA, 5 | DestroyRef, 6 | inject, 7 | input, 8 | signal, 9 | } from '@angular/core'; 10 | import { PokemonService } from '~features/pokemon/services/pokemon.service'; 11 | import { SlInputIconFocusDirective } from '~core/directives/sl-input-icon-focus.directive'; 12 | import { POKEMON_URLS } from '~core/constants/urls.constants'; 13 | import { Router } from '@angular/router'; 14 | import { NgOptimizedImage } from '@angular/common'; 15 | import { translations } from '../../../../../locale/translations'; 16 | import { AlertStore } from '~core/services/ui/alert.store'; 17 | import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 18 | import { TrimDirective } from '~core/directives/trim.directive'; 19 | 20 | import '@shoelace-style/shoelace/dist/components/button/button.js'; 21 | import '@shoelace-style/shoelace/dist/components/input/input.js'; 22 | import '@shoelace-style/shoelace/dist/components/icon/icon.js'; 23 | 24 | @Component({ 25 | changeDetection: ChangeDetectionStrategy.OnPush, 26 | selector: 'app-pokemon-search-input', 27 | templateUrl: './pokemon-search-input.component.html', 28 | styleUrl: './pokemon-search-input.component.scss', 29 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 30 | imports: [SlInputIconFocusDirective, NgOptimizedImage, TrimDirective], 31 | }) 32 | export class PokemonSearchInputComponent { 33 | private readonly router = inject(Router); 34 | private readonly pokemonService = inject(PokemonService); 35 | private readonly alertStore = inject(AlertStore); 36 | private readonly destroyRef = inject(DestroyRef); 37 | 38 | readonly title = input(translations.findPokemon); 39 | readonly termValue = signal(''); 40 | readonly pokemonLoading = signal(false); 41 | readonly searchState = computed(() => ({ 42 | isLoading: this.termValue() ? this.pokemonLoading() : false, 43 | showButton: this.termValue() && this.pokemonLoading() 44 | })); 45 | 46 | searchPokemon() { 47 | const pokemonName = this.termValue().toLowerCase(); 48 | if (pokemonName) { 49 | this.pokemonLoading.set(true); 50 | this.pokemonService 51 | .getPokemon(pokemonName) 52 | .pipe(takeUntilDestroyed(this.destroyRef)) 53 | .subscribe({ 54 | next: (pokemon) => { 55 | this.pokemonLoading.set(false); 56 | this.termValue.set(''); 57 | void this.router.navigate([POKEMON_URLS.detail(pokemon.name)]); 58 | }, 59 | error: () => { 60 | this.pokemonLoading.set(false); 61 | this.alertStore.createErrorAlert(translations.pokemonNotFoundError); 62 | }, 63 | }); 64 | } 65 | } 66 | 67 | assignInputValue(event: Event) { 68 | const inputEvent = event as CustomEvent; 69 | this.termValue.set((inputEvent.target as HTMLInputElement).value); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/app/features/pokemon/pages/my-pokemon/my-pokemon.component.html: -------------------------------------------------------------------------------- 1 |
2 |

My Pokemon

3 | 4 |
5 | 8 |
9 | 10 | @if (!userPokemons?.length) { 11 |
12 |
13 |

14 | Uh-oh, it looks like you haven’t caught any Pokémon yet! Need help finding a pokemon? 15 | Try using the search bar to track them down. Gotta catch ‘em all! 16 |

17 |
18 |
19 | ash jumping with a pokeball 27 |
28 |
29 | } @else { 30 |
    31 | @for (pokemon of userPokemons$ | ngrxPush; track pokemon.id) { 32 |
  • 33 | 34 |
  • 35 | } 36 |
37 | } 38 |
39 | 40 |
    41 |
  • 42 | 43 |
  • 44 |
  • 45 | 46 |
  • 47 |
  • 48 | 49 |
  • 50 |
51 |
52 |
53 | -------------------------------------------------------------------------------- /src/app/features/pokemon/pages/my-pokemon/my-pokemon.component.scss: -------------------------------------------------------------------------------- 1 | @use '../../../../../styles/base/media-queries' as mq; 2 | @use '../../../../../styles/components/pages'; 3 | 4 | $grid-columns: 3; 5 | $empty-image-width: 200px; 6 | 7 | :host { 8 | @include pages.read-page; 9 | 10 | text-align: center; 11 | 12 | .my-pokemon__search-container { 13 | display: flex; 14 | justify-content: center; 15 | margin-block-end: var(--spacing-r-5xl); 16 | } 17 | 18 | .my-pokemon__grid { 19 | display: grid; 20 | grid-template-columns: 1fr; 21 | gap: var(--spacing-r-xl); 22 | 23 | @include mq.for-tablet-up { 24 | grid-template-columns: repeat($grid-columns, 1fr); 25 | } 26 | } 27 | 28 | .pokemons-empty__container { 29 | display: flex; 30 | flex-direction: column; 31 | gap: var(--spacing-r-4xl); 32 | align-items: center; 33 | 34 | @include mq.for-tablet-up { 35 | margin-left: 60px; 36 | } 37 | 38 | .pokemons-empty__image { 39 | width: $empty-image-width; 40 | height: auto; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/app/features/pokemon/pages/my-pokemon/my-pokemon.component.ts: -------------------------------------------------------------------------------- 1 | import { ChangeDetectionStrategy, Component, CUSTOM_ELEMENTS_SCHEMA, inject } from '@angular/core'; 2 | import { UserService } from '~features/authentication/services/user.service'; 3 | import { PokemonCardComponent } from '~features/pokemon/components/pokemon-card/pokemon-card.component'; 4 | import { PokemonService } from '~features/pokemon/services/pokemon.service'; 5 | import { NgOptimizedImage } from '@angular/common'; 6 | import { translations } from '../../../../../locale/translations'; 7 | import { AlertStore } from '~core/services/ui/alert.store'; 8 | import { catchError, of, switchMap } from 'rxjs'; 9 | import { LetDirective, PushPipe } from '@ngrx/component'; 10 | import { PokemonSearchInputComponent } from '~features/pokemon/components/pokemon-search-input/pokemon-search-input.component'; 11 | 12 | @Component({ 13 | selector: 'app-my-pokemon', 14 | templateUrl: './my-pokemon.component.html', 15 | styleUrl: './my-pokemon.component.scss', 16 | imports: [ 17 | PokemonCardComponent, 18 | NgOptimizedImage, 19 | PushPipe, 20 | LetDirective, 21 | PokemonSearchInputComponent, 22 | ], 23 | changeDetection: ChangeDetectionStrategy.OnPush, 24 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 25 | }) 26 | export class MyPokemonComponent { 27 | private readonly userService = inject(UserService); 28 | private readonly pokemonService = inject(PokemonService); 29 | private readonly alertStore = inject(AlertStore); 30 | 31 | readonly translations = translations; 32 | readonly userPokemons$ = this.userService.getMe({ cache: false }).pipe( 33 | switchMap((user) => { 34 | if (user.caughtPokemonIds.length === 0) { 35 | return of([]); 36 | } 37 | return this.pokemonService.getPokemonByIds(user.caughtPokemonIds); 38 | }), 39 | catchError(() => { 40 | this.alertStore.createErrorAlert(translations.genericErrorAlert); 41 | return of([]); 42 | }), 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/app/features/pokemon/pages/pokemon-detail/pokemon-detail.component.html: -------------------------------------------------------------------------------- 1 | @let pokemon = pokemonResource.value(); 2 | @if (pokemon) { 3 | 4 |
5 | 6 |
7 | } 8 | -------------------------------------------------------------------------------- /src/app/features/pokemon/pages/pokemon-detail/pokemon-detail.component.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | @use 'components/pages'; 3 | 4 | $pokedex-container-offset: 60px; 5 | 6 | :host { 7 | @include pages.read-page; 8 | 9 | align-items: center; 10 | 11 | .pokedex__container { 12 | margin: var(--spacing-r-4xl) 0; 13 | 14 | @include mq.for-tablet-up { 15 | margin: var(--spacing-r-4xl) $pokedex-container-offset 0 0; 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/app/features/pokemon/pages/pokemon-detail/pokemon-detail.component.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ChangeDetectionStrategy, 3 | Component, 4 | CUSTOM_ELEMENTS_SCHEMA, 5 | effect, 6 | inject, 7 | signal, 8 | } from '@angular/core'; 9 | import { PokemonService } from '~features/pokemon/services/pokemon.service'; 10 | import type { Pokemon } from '~features/pokemon/types/pokemon.type'; 11 | import { ActivatedRoute } from '@angular/router'; 12 | import { PokemonBattlefieldComponent } from '~features/pokemon/components/pokemon-battlefield/pokemon-battlefield.component'; 13 | import { PokedexComponent } from '~features/pokemon/components/pokedex/pokedex.component'; 14 | import { BattleEvent } from '~features/pokemon/components/pokedex/enums/pokedex-action.enum'; 15 | import { translations } from '../../../../../locale/translations'; 16 | import { AlertStore } from '~core/services/ui/alert.store'; 17 | import { toSignal } from '@angular/core/rxjs-interop'; 18 | import { map } from 'rxjs'; 19 | 20 | @Component({ 21 | selector: 'app-pokemon-detail', 22 | templateUrl: './pokemon-detail.component.html', 23 | styleUrl: './pokemon-detail.component.scss', 24 | changeDetection: ChangeDetectionStrategy.OnPush, 25 | schemas: [CUSTOM_ELEMENTS_SCHEMA], 26 | imports: [PokemonBattlefieldComponent, PokedexComponent], 27 | }) 28 | export class PokemonDetailComponent { 29 | private readonly activatedRoute = inject(ActivatedRoute); 30 | private readonly pokemonService = inject(PokemonService); 31 | private readonly alertStore = inject(AlertStore); 32 | 33 | readonly pokemonId = toSignal( 34 | this.activatedRoute.paramMap.pipe(map((parameters) => parameters.get('pokemonId') ?? '')), 35 | { initialValue: '' }, 36 | ); 37 | readonly pokemonResource = this.pokemonService.getPokemonResource(this.pokemonId); 38 | readonly pokemon = signal(null); 39 | 40 | // eslint-disable-next-line @angular-eslint/prefer-signals 41 | pokemonBattleEvent = signal(BattleEvent.POKEMON_LOADED); 42 | 43 | constructor() { 44 | effect(() => { 45 | if (this.pokemonResource.value()) { 46 | this.pokemonBattleEvent.set(BattleEvent.RESET_BATTLE); 47 | } 48 | if (this.pokemonResource.error()) { 49 | this.alertStore.createErrorAlert(translations.pokemonNotFoundError); 50 | } 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/app/features/pokemon/pokemon.routes.ts: -------------------------------------------------------------------------------- 1 | import type { Route } from '@angular/router'; 2 | import { ROOT_PATHS } from '~core/constants/paths.constants'; 3 | import { PokemonDetailComponent } from '~features/pokemon/pages/pokemon-detail/pokemon-detail.component'; 4 | import { authenticationGuard } from '~core/guards/authentication.guard'; 5 | 6 | export const POKEMON_ROUTES: Route[] = [ 7 | { 8 | path: ':pokemonId', 9 | component: PokemonDetailComponent, 10 | canActivate: [authenticationGuard], 11 | }, 12 | { path: '**', redirectTo: ROOT_PATHS.error404 }, 13 | ]; 14 | -------------------------------------------------------------------------------- /src/app/features/pokemon/services/crop-image.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@angular/core'; 2 | 3 | @Injectable({ 4 | providedIn: 'root', 5 | }) 6 | export class CropImageService { 7 | async getCroppedImageURL(canvas: HTMLCanvasElement, imageUrl: string): Promise { 8 | return new Promise((resolve, reject) => { 9 | const context = canvas.getContext('2d', { willReadFrequently: true }); 10 | if (!context) { 11 | reject(new Error('Canvas context not found')); 12 | return; 13 | } 14 | 15 | const image = new Image(); 16 | image.crossOrigin = 'Anonymous'; 17 | image.src = imageUrl; 18 | image.addEventListener('load', () => { 19 | canvas.width = image.width; 20 | canvas.height = image.height; 21 | context.drawImage(image, 0, 0); 22 | const croppedImageUrl = this.cropImageToFitContent({ context, image, canvas }); 23 | resolve(croppedImageUrl); 24 | }); 25 | 26 | image.addEventListener('error', () => { 27 | reject(new Error('Image failed to load')); 28 | }); 29 | }); 30 | } 31 | 32 | private cropImageToFitContent({ 33 | context, 34 | image, 35 | canvas, 36 | }: { 37 | context: CanvasRenderingContext2D; 38 | image: HTMLImageElement; 39 | canvas: HTMLCanvasElement; 40 | }): string { 41 | const imageData = context.getImageData(0, 0, canvas.width, canvas.height); 42 | const { top, bottom, left, right } = this.findCropBoundaries(imageData, canvas); 43 | const croppedWidth = Math.max(right - left, 1); 44 | const croppedHeight = Math.max(bottom - top, 1); 45 | return this.createCroppedImage({ image, left, top, croppedWidth, croppedHeight }); 46 | } 47 | 48 | // eslint-disable-next-line max-statements 49 | private findCropBoundaries(imageData: ImageData, canvas: HTMLCanvasElement) { 50 | let bottom = 0, 51 | left = canvas.width, 52 | right = 0, 53 | top = canvas.height; 54 | 55 | for (let row = 0; row < canvas.height; row++) { 56 | for (let column = 0; column < canvas.width; column++) { 57 | const index = (row * canvas.width + column) * 4; 58 | const alpha = imageData.data[index + 3]; 59 | if (alpha > 0) { 60 | // Update boundaries for non-transparent pixel 61 | top = Math.min(top, row); 62 | bottom = Math.max(bottom, row); 63 | left = Math.min(left, column); 64 | right = Math.max(right, column); 65 | } 66 | } 67 | } 68 | 69 | return { top, bottom, left, right }; 70 | } 71 | 72 | // eslint-disable-next-line max-lines-per-function 73 | private createCroppedImage({ 74 | image, 75 | left, 76 | top, 77 | croppedWidth, 78 | croppedHeight, 79 | }: { 80 | image: HTMLImageElement; 81 | left: number; 82 | top: number; 83 | croppedWidth: number; 84 | croppedHeight: number; 85 | }): string { 86 | const croppedCanvas = document.createElement('canvas'); 87 | const croppedContext = croppedCanvas.getContext('2d'); 88 | if (!croppedContext) { 89 | return ''; 90 | } 91 | croppedCanvas.width = croppedWidth; 92 | croppedCanvas.height = croppedHeight; 93 | 94 | croppedContext.drawImage( 95 | image, 96 | left, 97 | top, 98 | croppedWidth, 99 | croppedHeight, 100 | 0, 101 | 0, 102 | croppedWidth, 103 | croppedHeight, 104 | ); 105 | 106 | return croppedCanvas.toDataURL(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/app/features/pokemon/services/pokemon.service.ts: -------------------------------------------------------------------------------- 1 | import { inject, Injectable } from '@angular/core'; 2 | import type { Observable } from 'rxjs'; 3 | import { forkJoin, map } from 'rxjs'; 4 | import type { HttpResourceRef } from '@angular/common/http'; 5 | import { HttpClient, HttpContext, HttpParams, httpResource } from '@angular/common/http'; 6 | import { CACHING_ENABLED } from '~core/interceptors/caching.interceptor'; 7 | import type { Pokemon } from '~features/pokemon/types/pokemon.type'; 8 | import { getEndpoints } from '~core/constants/endpoints.constants'; 9 | 10 | @Injectable({ 11 | providedIn: 'root', 12 | }) 13 | export class PokemonService { 14 | private readonly endpoints = getEndpoints(); 15 | private readonly httpClient = inject(HttpClient); 16 | 17 | getPokemon(pokemonIdOrName: string | number): Observable { 18 | return this.httpClient.get(this.endpoints.pokemon.v1.pokemon(pokemonIdOrName), { 19 | params: new HttpParams().set('limit', '1'), 20 | context: new HttpContext().set(CACHING_ENABLED, true), 21 | }); 22 | } 23 | 24 | getPokemonResource(pokemonName: () => string | undefined): HttpResourceRef { 25 | return httpResource(() => 26 | pokemonName() ? this.endpoints.pokemon.v1.pokemon(pokemonName()!) : undefined, 27 | ); 28 | } 29 | 30 | getPokemonByIds(ids: number[]): Observable { 31 | const getPokemonRequests = ids.map((id) => this.getPokemon(id)); 32 | return forkJoin(getPokemonRequests).pipe( 33 | map((pokemons: Pokemon[]) => 34 | pokemons.sort((pokemonA, pokemonB) => Number(pokemonA.order) - Number(pokemonB.order)), 35 | ), 36 | ); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/app/features/pokemon/types/pokemon.type.ts: -------------------------------------------------------------------------------- 1 | export type Pokemon = { 2 | id: number; 3 | order: string; 4 | name: string; 5 | height: string; 6 | weight: string; 7 | sprites: { 8 | front_default: string; 9 | front_shiny: string; 10 | }; 11 | }; 12 | -------------------------------------------------------------------------------- /src/environments/environment.production.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | domain: 'https://angular-example-app.netlify.app', 3 | apiBaseUrl: 'https://nestjs-example-app.fly.dev', 4 | }; 5 | -------------------------------------------------------------------------------- /src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build` replaces `environment.ts` with `environment.production.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | domain: 'http://localhost:4200', 7 | // ApiBaseUrl: 'http://localhost:3000', // For local development with https://github.com/Ismaestro/nestjs-example-app 8 | apiBaseUrl: 'http://localhost:3000', 9 | }; 10 | -------------------------------------------------------------------------------- /src/locale/translations.ts: -------------------------------------------------------------------------------- 1 | export const translations = { 2 | title: $localize`Angular Example App`, 3 | home: $localize`Home`, 4 | logIn: $localize`Log in`, 5 | register: $localize`Register`, 6 | myAccount: $localize`My account`, 7 | myPokemon: $localize`My pokemon`, 8 | logOut: $localize`Log out`, 9 | fieldRequired: $localize`Field required.`, 10 | emailHelpText: $localize`Field required. No real email validation. Format: example@domain.com`, 11 | passwordHelpText: $localize`Must contain at least one lowercase letter, one uppercase letter and one number. No special characters.`, 12 | confirmPasswordHelpText: $localize`Passwords do not match.`, 13 | pokemonHelpText: $localize`Field required. PokeAPI does not found that pokemon name.`, 14 | logout: $localize`Log out`, 15 | findMore: $localize`Find more!`, 16 | findPokemon: $localize`Find a pokemon`, 17 | myAccountSuccessAlert: $localize`Account settings saved. You're all set!`, 18 | genericErrorAlert: $localize`Oops! Something went wrong. Please try again later or leave an issue if it persists.`, 19 | loginCredentialsError: $localize`Invalid credentials. Not very effective, try again!`, 20 | genericRegisterError: $localize`Register failed. This attempt wasn’t very effective, try again!`, 21 | pokemonNotFoundError: $localize`Pokémon not found. Double-check the name and try again!`, 22 | sessionExpired: $localize`Session expired. Please log in.`, 23 | }; 24 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import { bootstrapApplication } from '@angular/platform-browser'; 3 | import { AppComponent } from './app/app.component'; 4 | import { appConfig } from './app/app.config'; 5 | import { setBasePath } from '@shoelace-style/shoelace/dist/utilities/base-path.js'; 6 | 7 | setBasePath('https://cdn.jsdelivr.net/npm/@shoelace-style/shoelace@2.17.1/cdn/'); 8 | 9 | bootstrapApplication(AppComponent, appConfig).catch((error) => { 10 | // eslint-disable-next-line no-console 11 | console.error(error); 12 | }); 13 | -------------------------------------------------------------------------------- /src/styles/base/_border-radius.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --border-radius-xs: 0.125rem; 3 | --border-radius-sm: 0.25rem; 4 | --border-radius-md: 0.375rem; 5 | --border-radius-lg: 0.5rem; 6 | --border-radius-xl: 0.625rem; 7 | --border-radius-xxl: 0.75rem; 8 | --border-radius-max: 2.75rem; 9 | } 10 | -------------------------------------------------------------------------------- /src/styles/base/_media-queries.scss: -------------------------------------------------------------------------------- 1 | $screen-xs: 700px; 2 | $screen-sm: 775px; 3 | $screen-md: 900px; 4 | $screen-lg: 1200px; 5 | $screen-xl: 1800px; 6 | 7 | @mixin for-phone-only { 8 | @media (max-width: $screen-sm) { 9 | @content; 10 | } 11 | } 12 | 13 | @mixin for-tablet-portrait-up { 14 | @media (min-width: $screen-xs) { 15 | @content; 16 | } 17 | } 18 | 19 | @mixin for-tablet { 20 | @media (min-width: $screen-xs) and (max-width: $screen-md) { 21 | @content; 22 | } 23 | } 24 | 25 | @mixin for-tablet-up { 26 | @media (min-width: $screen-sm) { 27 | @content; 28 | } 29 | } 30 | 31 | @mixin for-tablet-landscape-up { 32 | @media (min-width: $screen-md) { 33 | @content; 34 | } 35 | } 36 | 37 | @mixin for-desktop-up { 38 | @media (min-width: $screen-lg) { 39 | @content; 40 | } 41 | } 42 | 43 | @mixin for-big-desktop-up { 44 | @media (min-width: $screen-xl) { 45 | @content; 46 | } 47 | } 48 | 49 | @mixin for-desktop-down { 50 | @media (max-width: $screen-lg) { 51 | @content; 52 | } 53 | } 54 | 55 | @mixin for-tablet-landscape-down { 56 | @media (max-width: $screen-md) { 57 | @content; 58 | } 59 | } 60 | 61 | @mixin for-tablet-down { 62 | @media (max-width: $screen-sm) { 63 | @content; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/styles/base/_primitive-colors.scss: -------------------------------------------------------------------------------- 1 | /* 2 | ============================================================ 3 | ⚠️ Primitive Colors ⚠️ 4 | ============================================================ 5 | STRICTLY FORBIDDEN: DO NOT use these color variables anywhere else in the application. 6 | These are **ONLY** for the color themes (_themes.scss). Any other usage will cause inconsistency 7 | and break the design system. 8 | ============================================================ 9 | */ 10 | 11 | // Using OKLCH color space for better color reproduction on P3 displays, as well as better human-readability 12 | // https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/oklch 13 | @mixin primitive-colors() { 14 | // Base 15 | --primitive-bright-blue: oklch(51.01% 0.274 263.83deg); // #0546ff 16 | --primitive-subtle-purple: oklch(33.72% 0.0108 271.08deg); // #35373D 17 | --primitive-cotton-ball: oklch(97.54% 0.0115 264.51deg); // #f3f7ff 18 | --primitive-indigo-blue: oklch(51.64% 0.229 281.65deg); // #5c44e4 19 | --primitive-vivid-pink: oklch(69.02% 0.277 332.77deg); // #f637e3 20 | --primitive-electric-violet: oklch(53.18% 0.28 296.97deg); // #8514f5 21 | --primitive-hot-red: oklch(61.42% 0.238 15.34deg); // #f11653 22 | --primitive-orange-red: oklch(63.32% 0.24 31.68deg); // #fa2c04 23 | --primitive-vitalize-green: oklch(64.01% 0.1751 146.74deg); // #28a745 24 | --primitive-peach-echo: oklch(12.16% 0.079 270.91deg); // #020024 25 | --primitive-kissed-mist: oklch(88.15% 0.0908 328.72deg); // #fac3f6 26 | --primitive-pink-illusion: oklch(83.64% 0.0968 307.17deg); // #dab9fb 27 | 28 | // Mixed 29 | --primitive-bright-blue-mixed: color-mix( 30 | in srgb, 31 | oklch(51.01% 0.274 263.83deg), 32 | var(--full-contrast) 60% 33 | ); 34 | --primitive-vivid-pink-mixed: color-mix( 35 | in srgb, 36 | oklch(69.02% 0.277 332.77deg), 37 | var(--full-contrast) 70% 38 | ); 39 | --primitive-hot-red-mixed: color-mix( 40 | in srgb, 41 | oklch(61.42% 0.238 15.34deg), 42 | var(--full-contrast) 70% 43 | ); 44 | --primitive-orange-red-mixed: color-mix( 45 | in srgb, 46 | oklch(63.32% 0.24 31.68deg), 47 | var(--full-contrast) 60% 48 | ); 49 | --primitive-electric-violet-mixed: color-mix( 50 | in srgb, 51 | oklch(53.18% 0.28 296.97deg), 52 | var(--full-contrast) 70% 53 | ); 54 | 55 | // Full 56 | --primitive-full-white: white; 57 | --primitive-full-black: black; 58 | 59 | // Grays 60 | --primitive-gray-1000: oklch(16.93% 0.004 285.95deg); // #0f0f11 61 | --primitive-gray-900: oklch(19.37% 0.006 300.98deg); // #151417 62 | --primitive-gray-800: oklch(25.16% 0.008 308.11deg); // #232125 63 | --primitive-gray-700: oklch(36.98% 0.014 302.71deg); // #413e46 64 | --primitive-gray-600: oklch(44% 0.019 306.08deg); // #55505b 65 | --primitive-gray-500: oklch(54.84% 0.023 304.99deg); // #746e7c 66 | --primitive-gray-400: oklch(70.9% 0.015 304.04deg); // #a39fa9 67 | --primitive-gray-300: oklch(84.01% 0.009 308.34deg); // #ccc9cf 68 | --primitive-gray-200: oklch(91.75% 0.004 301.42deg); // #e4e3e6 69 | --primitive-gray-100: oklch(97.12% 0.002 325.59deg); // #f6f5f6 70 | --primitive-gray-50: oklch(98.81% 0 0deg); // #fbfbfb 71 | 72 | // Gradients 73 | --pink-to-highlight-to-purple-to-blue-horizontal-gradient: linear-gradient( 74 | 140deg, 75 | var(--primitive-vivid-pink) 0%, 76 | var(--primitive-vivid-pink) 15%, 77 | color-mix(in srgb, var(--primitive-vivid-pink), var(--primitive-electric-violet) 50%) 25%, 78 | color-mix(in srgb, var(--primitive-vivid-pink), var(--primitive-electric-violet) 10%) 35%, 79 | color-mix(in srgb, var(--primitive-vivid-pink), var(--primitive-orange-red) 50%) 42%, 80 | color-mix(in srgb, var(--primitive-vivid-pink), var(--primitive-orange-red) 50%) 44%, 81 | color-mix(in srgb, var(--primitive-vivid-pink), var(--page-background) 70%) 47%, 82 | var(--primitive-electric-violet) 48%, 83 | var(--primitive-bright-blue) 60% 84 | ); 85 | --pink-to-purple-horizontal-gradient: linear-gradient( 86 | 90deg, 87 | var(--primitive-peach-echo) 0%, 88 | var(--primitive-kissed-mist) 0%, 89 | var(--primitive-pink-illusion) 100% 90 | ); 91 | 92 | // Mixed gradients 93 | --pink-to-highlight-to-purple-to-blue-horizontal-mixed-gradient: linear-gradient( 94 | 140deg, 95 | var(--primitive-vivid-pink-mixed) 0%, 96 | var(--primitive-vivid-pink-mixed) 15%, 97 | color-mix( 98 | in srgb, 99 | var(--primitive-vivid-pink-mixed), 100 | var(--primitive-electric-violet-mixed) 50% 101 | ) 102 | 25%, 103 | color-mix( 104 | in srgb, 105 | var(--primitive-vivid-pink-mixed), 106 | var(--primitive-electric-violet-mixed) 10% 107 | ) 108 | 35%, 109 | color-mix(in srgb, var(--primitive-vivid-pink-mixed), var(--primitive-orange-red-mixed) 50%) 42%, 110 | color-mix(in srgb, var(--primitive-vivid-pink-mixed), var(--primitive-orange-red-mixed) 50%) 44%, 111 | color-mix(in srgb, var(--primitive-vivid-pink-mixed), var(--page-background) 70%) 47%, 112 | var(--primitive-electric-violet-mixed) 48%, 113 | var(--primitive-bright-blue-mixed) 60% 114 | ); 115 | } 116 | -------------------------------------------------------------------------------- /src/styles/base/_reset.scss: -------------------------------------------------------------------------------- 1 | @use 'media-queries' as mq; 2 | 3 | @mixin reset() { 4 | :root { 5 | --page-width: 80ch; 6 | --layout-padding: var(--spacing-r-xl); 7 | 8 | @include mq.for-tablet-up { 9 | --layout-padding: var(--spacing-r-4xl); 10 | } 11 | } 12 | 13 | html { 14 | font-family: var(--inter-font), serif; 15 | font-size: var(--font-size-md); 16 | color: var(--page-color); 17 | background-color: var(--page-background); 18 | transition: 19 | color 0.3s ease, 20 | background-color 0.3s ease; 21 | -webkit-font-smoothing: antialiased; 22 | -moz-osx-font-smoothing: grayscale; 23 | scroll-behavior: smooth; 24 | } 25 | 26 | @media (prefers-reduced-motion) { 27 | html { 28 | scroll-behavior: auto; 29 | } 30 | } 31 | 32 | body { 33 | margin: 0; 34 | overflow: hidden auto; 35 | } 36 | 37 | html, 38 | body { 39 | height: 100vh; 40 | min-height: 100vh; 41 | 42 | @supports (height: 100svh) { 43 | height: 100svh; 44 | } 45 | } 46 | 47 | button { 48 | cursor: pointer; 49 | } 50 | 51 | img { 52 | width: 100%; 53 | margin: 0; 54 | overflow: hidden; 55 | border-radius: var(--border-radius-sm); 56 | } 57 | 58 | abbr[title] { 59 | text-decoration: none; 60 | } 61 | 62 | h1 { 63 | margin: 0; 64 | } 65 | 66 | ul { 67 | padding-inline-start: 0; 68 | 69 | li { 70 | list-style: none; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/styles/base/_spacing.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --spacing-xs: 2px; 3 | --spacing-sm: 4px; 4 | --spacing-md: 8px; 5 | --spacing-lg: 12px; 6 | --spacing-xl: 16px; 7 | --spacing-xxl: 20px; 8 | --spacing-3xl: 24px; 9 | --spacing-4xl: 32px; 10 | --spacing-5xl: 40px; 11 | --spacing-6xl: 48px; 12 | --spacing-r-xs: 0.125rem; 13 | --spacing-r-sm: 0.375rem; 14 | --spacing-r-md: 0.5rem; 15 | --spacing-r-lg: 0.75rem; 16 | --spacing-r-xl: 1rem; 17 | --spacing-r-xxl: 1.25rem; 18 | --spacing-r-3xl: 1.5rem; 19 | --spacing-r-4xl: 2rem; 20 | --spacing-r-5xl: 2.5rem; 21 | --spacing-r-6xl: 3rem; 22 | --spacing-r-7xl: 3.5rem; 23 | --spacing-r-8xl: 4rem; 24 | --spacing-r-9xl: 4.5rem; 25 | --spacing-r-10xl: 5rem; 26 | --spacing-r-11xl: 5.5rem; 27 | --spacing-r-12xl: 6rem; 28 | } 29 | -------------------------------------------------------------------------------- /src/styles/base/_themes.scss: -------------------------------------------------------------------------------- 1 | @use 'primitive-colors'; 2 | @use 'color-definitions'; 3 | 4 | @mixin themes() { 5 | .theme-dark--mode { 6 | @include primitive-colors.primitive-colors; 7 | @include color-definitions.dark-definitions; 8 | 9 | background-color: var(--page-background); 10 | } 11 | 12 | .theme-light--mode { 13 | @include primitive-colors.primitive-colors; 14 | @include color-definitions.light-definitions; 15 | 16 | background-color: var(--page-background); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/styles/base/_typography.scss: -------------------------------------------------------------------------------- 1 | @mixin typography() { 2 | :root { 3 | --fallback-font-stack: ui-sans-serif, system-ui, -apple-system, blinkmacsystemfont, 'Segoe UI', 4 | roboto, 'Helvetica Neue', arial, 'Noto Sans', sans-serif, 'Apple Color Emoji', 5 | 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji'; 6 | --code-font: 'DM Mono', monospace; 7 | --inter-font: 'Inter', var(--fallback-font-stack); 8 | --inter-tight-font: 'Inter Tight', var(--fallback-font-stack); 9 | 10 | // Font weight 11 | --font-weight-light: 300; 12 | --font-weight-regular: 400; 13 | --font-weight-xregular: 500; 14 | --font-weight-bold: 700; 15 | 16 | // Font style 17 | --font-style-normal: normal; 18 | 19 | // Font size 20 | --font-size-xs: 0.8125rem; 21 | --font-size-sm: 0.875rem; 22 | --font-size-md: 1rem; 23 | --font-size-lg: 1.5rem; 24 | --font-size-xl: 2rem; 25 | --font-size-xxl: 2.25rem; 26 | 27 | // Line height 28 | --line-height-xs: 0.9rem; 29 | --line-height-sm: 1rem; 30 | --line-height-md: 1.25rem; 31 | --line-height-lg: 1.5rem; 32 | --line-height-xl: 2rem; 33 | --line-height-xxl: 2.5rem; 34 | --line-height-max: 3.5rem; 35 | 36 | // Letter spacing 37 | --letter-spacing-sm: -0.0088rem; 38 | --letter-spacing-md: -0.01rem; 39 | --letter-spacing-lg: -0.025rem; 40 | } 41 | 42 | h1, 43 | h2, 44 | h3, 45 | h4, 46 | h5, 47 | h6 { 48 | margin: 0; 49 | font-family: var(--inter-tight-font), serif; 50 | font-weight: var(--font-weight-xregular); 51 | text-wrap: balance; 52 | } 53 | 54 | h1 { 55 | font-size: var(--font-size-xxl); 56 | } 57 | 58 | h2 { 59 | margin-block: var(--spacing-r-3xl) var(--spacing-r-md); 60 | font-size: var(--font-size-xl); 61 | } 62 | 63 | p { 64 | margin-block: 0 var(--spacing-r-xl); 65 | font-size: var(--font-size-sm); 66 | font-weight: var(--font-weight-regular); 67 | line-height: var(--line-height-lg); 68 | letter-spacing: var(--letter-spacing-sm); 69 | } 70 | 71 | p ~ ul, 72 | p ~ ol { 73 | margin-block-start: 0; 74 | } 75 | 76 | ul, 77 | ol { 78 | font-size: var(--font-size-sm); 79 | font-weight: var(--font-weight-regular); 80 | line-height: var(--line-height-lg); 81 | letter-spacing: var(--letter-spacing-md); 82 | } 83 | 84 | a { 85 | font-weight: var(--font-weight-xregular); 86 | text-decoration: none; 87 | } 88 | 89 | hr { 90 | width: 100%; 91 | margin-block: var(--spacing-r-xl); 92 | border: 0; 93 | border-color: var(--senary-contrast); 94 | border-style: solid; 95 | border-block-start-width: 1px; 96 | transition: border-color 0.3s ease; 97 | } 98 | 99 | .text--medium { 100 | font-size: larger; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/styles/base/_z-index.scss: -------------------------------------------------------------------------------- 1 | :root { 2 | --z-index-skip-button: 1000; 3 | --z-index-cookie-consent: 60; 4 | } 5 | -------------------------------------------------------------------------------- /src/styles/components/_alerts.scss: -------------------------------------------------------------------------------- 1 | @mixin alerts() { 2 | sl-alert { 3 | &::part(base) { 4 | font-size: var(--font-size-sm); 5 | background-color: var(--page-background); 6 | border-radius: var(--border-radius-sm); 7 | box-shadow: 0 0 10px 0 rgb(0 0 0 / 10%); 8 | transition: 9 | background-color 0.3s ease, 10 | border-color 0.3s ease, 11 | color 0.3s ease; 12 | } 13 | 14 | &::part(message), 15 | &::part(close-button) { 16 | color: var(--primary-contrast); 17 | } 18 | 19 | &.alert--success { 20 | &::part(base) { 21 | border: 1px solid var(--status-color-success); 22 | } 23 | } 24 | 25 | &.alert--error { 26 | &::part(base) { 27 | border: 1px solid var(--status-color-error); 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/styles/components/_buttons.scss: -------------------------------------------------------------------------------- 1 | $pokedex-background-color: #f8f8f8; 2 | $pokedex-text-color: #4e4e4e; 3 | $pokedex-button-border-color: #7d7897; 4 | 5 | @mixin buttons() { 6 | sl-button { 7 | &::part(base) { 8 | display: flex; 9 | align-items: center; 10 | border: 0; 11 | border-radius: var(--border-radius-max); 12 | transition: background 0.3s ease; 13 | } 14 | 15 | &::part(label) { 16 | padding: 0; 17 | } 18 | 19 | &.button--primary::part(base), 20 | &.dropdown-button--primary::part(base) { 21 | padding-block: var(--spacing-r-sm); 22 | padding-inline: var(--spacing-r-xl); 23 | font-family: var(--inter-font), serif; 24 | font-size: var(--font-size-sm); 25 | font-weight: var(--font-weight-xregular); 26 | line-height: var(--line-height-lg); 27 | color: var(--buttons-color); 28 | letter-spacing: -0.0088rem; 29 | background: var(--buttons-background); 30 | 31 | &:hover { 32 | background: var(--buttons-background-hover); 33 | } 34 | } 35 | 36 | &.dropdown-button--primary::part(label) { 37 | margin-right: var(--spacing-r-sm); 38 | font-weight: var(--font-weight-bold); 39 | } 40 | 41 | &.button--icon::part(base) { 42 | padding-inline: 0; 43 | font-size: var(--font-size-lg); 44 | color: var(--icons-color); 45 | background: transparent; 46 | 47 | &:hover { 48 | color: var(--icons-color-hover); 49 | background: transparent; 50 | transition: color 0.3s ease; 51 | } 52 | } 53 | 54 | &.button__as-link--primary { 55 | &::part(base) { 56 | color: var(--text-color-secondary); 57 | background: transparent; 58 | border: 0; 59 | 60 | &:hover { 61 | color: var(--text-color-secondary-hover); 62 | } 63 | 64 | &:active { 65 | color: var(--text-color-secondary-hover); 66 | } 67 | } 68 | 69 | &::part(label) { 70 | font-size: var(--font-size-md); 71 | } 72 | } 73 | 74 | &.button--pokemon-style::part(base) { 75 | position: relative; 76 | min-height: 0; 77 | padding: var(--spacing-sm) var(--spacing-xl); 78 | color: $pokedex-text-color; 79 | background: $pokedex-background-color; 80 | border: 3px solid $pokedex-button-border-color; 81 | border-radius: var(--border-radius-sm); 82 | 83 | &::before { 84 | position: absolute; 85 | top: 50%; 86 | left: var(--spacing-xs); 87 | content: '▶'; 88 | opacity: 0; 89 | transform: translateY(-50%); 90 | transition: opacity 0.1s; 91 | } 92 | 93 | &:hover { 94 | color: $pokedex-text-color; 95 | 96 | &::before { 97 | opacity: 1; 98 | } 99 | } 100 | 101 | &:active { 102 | color: $pokedex-text-color; 103 | } 104 | } 105 | 106 | &.button--pokemon-style::part(label) { 107 | font-size: var(--font-size-xs); 108 | font-weight: var(--font-weight-bold); 109 | line-height: var(--line-height-md); 110 | opacity: 0.8; 111 | 112 | &:hover { 113 | opacity: 1; 114 | } 115 | } 116 | 117 | &.button--image::part(base) { 118 | background: transparent; 119 | border: 0; 120 | } 121 | 122 | &.dropdown-button--avatar::part(base) { 123 | box-shadow: 0 0 0 1px var(--text-color-secondary); 124 | transition: box-shadow 0.5s ease; 125 | 126 | &:hover { 127 | box-shadow: 0 0 0 1px var(--text-color-secondary-hover); 128 | } 129 | } 130 | 131 | &.dropdown-button--avatar::part(label) { 132 | display: flex; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/styles/components/_checkboxes.scss: -------------------------------------------------------------------------------- 1 | @mixin checkboxes() { 2 | sl-checkbox.checkbox--primary { 3 | --sl-input-required-content: ''; 4 | --sl-input-required-content-offset: 0; 5 | 6 | &::part(base) { 7 | font-size: var(--font-size-sm); 8 | color: var(--checkboxes-color); 9 | text-align: start; 10 | transition: 11 | color 0.3s ease, 12 | background-color 0.3s ease, 13 | border-color 0.3s ease; 14 | } 15 | 16 | &::part(form-control-help-text) { 17 | text-align: start; 18 | } 19 | 20 | &::part(checked-icon) { 21 | color: var(--checkboxes-checked-icon-color); 22 | } 23 | 24 | &::part(control) { 25 | background: var(--checkboxes-control-background); 26 | border-radius: 1px; 27 | } 28 | 29 | &:hover { 30 | sl-icon { 31 | color: var(--icons-color-hover); 32 | } 33 | } 34 | } 35 | 36 | sl-checkbox.ng-invalid.ng-touched:not(form) { 37 | &::part(form-control-help-text), 38 | &::part(label) { 39 | color: var(--status-color-error); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/components/_dropdowns.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | @mixin dropdowns() { 4 | .dropdown__content-container { 5 | margin-block-start: var(--spacing-r-xl); 6 | background: var(--dropdowns-background); 7 | border: 1px solid var(--senary-contrast); 8 | border-radius: var(--border-radius-sm); 9 | box-shadow: none; 10 | 11 | .dropdown__item-container { 12 | &:hover { 13 | color: var(--dropdown-items-color); 14 | background: var(--dropdown-items-background-hover); 15 | transition: background 0.3s ease; 16 | } 17 | 18 | a { 19 | display: block; 20 | width: 100%; 21 | height: 100%; 22 | padding: var(--spacing-r-xl); 23 | font-size: var(--font-size-md); 24 | color: var(--text-color-secondary); 25 | text-decoration: none; 26 | 27 | &:hover { 28 | color: var(--text-color-secondary-hover); 29 | } 30 | } 31 | 32 | sl-button.button__as-link--primary { 33 | &::part(base) { 34 | display: block; 35 | width: 100%; 36 | height: 100%; 37 | padding: var(--spacing-r-md) var(--spacing-r-xl); 38 | } 39 | } 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/styles/components/_forms.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | @mixin forms() { 4 | .form__container { 5 | padding: var(--spacing-r-5xl) var(--spacing-r-xl); 6 | margin: 0 auto; 7 | border: 1px solid var(--senary-contrast); 8 | 9 | @include mq.for-tablet-up { 10 | padding: var(--spacing-r-6xl) var(--spacing-r-xl); 11 | } 12 | 13 | .form-control__container { 14 | margin-block: var(--spacing-r-3xl); 15 | 16 | .button--primary { 17 | margin-block-start: var(--spacing-r-md); 18 | } 19 | 20 | &:first-of-type { 21 | margin-block-start: 0; 22 | } 23 | 24 | @include mq.for-tablet-up { 25 | margin-block: var(--spacing-r-3xl); 26 | } 27 | } 28 | 29 | .form-footer__paragraph { 30 | margin: 0; 31 | 32 | .form-footer__link { 33 | font-size: var(--font-size-sm); 34 | } 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/styles/components/_headings.scss: -------------------------------------------------------------------------------- 1 | @use 'base/media-queries' as mq; 2 | 3 | @mixin headings() { 4 | .first-heading__title { 5 | margin-block-end: var(--spacing-r-3xl); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/styles/components/_inputs.scss: -------------------------------------------------------------------------------- 1 | $loading-icon-suffix-width: 30px; 2 | 3 | @mixin inputs() { 4 | sl-input.input--primary { 5 | --sl-input-background-color: var(--inputs-background-color); 6 | --sl-input-background-color-hover: var(--inputs-background-color-hover); 7 | --sl-input-background-color-focus: var(--inputs-background-color-focus); 8 | --sl-input-border-color: var(--inputs-border-color); 9 | --sl-input-border-color-hover: var(--inputs-border-color-hover); 10 | --sl-input-border-color-focus: var(--inputs-border-color-focus); 11 | --sl-input-font-family: var(--inter-font); 12 | --sl-input-font-size-medium: var(--font-size-md); 13 | --sl-input-color: var(--inputs-color); 14 | --sl-input-color-hover: var(--inputs-color-hover); 15 | --sl-input-color-focus: var(--inputs-color-focus); 16 | --sl-input-placeholder-color: var(--inputs-placeholder-color); 17 | --sl-input-focus-ring-color: var(--inputs-focus-ring-color); 18 | --sl-input-focus-ring-offset: 0; 19 | --sl-input-required-content: ''; 20 | --sl-input-required-content-offset: 0; 21 | 22 | &::part(base) { 23 | transition: 24 | color 0.3s ease, 25 | background-color 0.3s ease, 26 | border-color 0.3s ease; 27 | } 28 | 29 | &::part(input)::placeholder { 30 | font-size: var(--font-size-sm); 31 | } 32 | 33 | &::part(form-control) { 34 | text-align: start; 35 | } 36 | 37 | &::part(form-control-label) { 38 | margin-block-end: var(--spacing-r-sm); 39 | } 40 | 41 | &::part(form-control-help-text) { 42 | margin-top: var(--spacing-r-sm); 43 | font-size: var(--font-size-xs); 44 | color: var(--inputs-placeholder-color); 45 | } 46 | 47 | .loading__image { 48 | width: $loading-icon-suffix-width; 49 | height: auto; 50 | margin-inline-end: var(--spacing-r-sm); 51 | } 52 | 53 | sl-icon { 54 | transition: color 0.3s ease; 55 | } 56 | } 57 | 58 | sl-input:not([disabled]):hover sl-icon { 59 | color: var(--icons-color-hover); 60 | } 61 | 62 | sl-input.ng-invalid.ng-touched:not(form) { 63 | &::part(form-control-input) { 64 | --sl-input-border-color: var(--status-color-error); 65 | --sl-input-border-color-hover: var(--status-color-error); 66 | --sl-input-border-color-focus: var(--status-color-error); 67 | --sl-input-placeholder-color: var(--status-color-error); 68 | } 69 | 70 | &::part(form-control-help-text) { 71 | color: var(--status-color-error); 72 | } 73 | } 74 | 75 | sl-input.ng-valid.ng-touched:not(form) { 76 | &::part(form-control-input) { 77 | --sl-input-border-color: var(--status-color-success); 78 | --sl-input-border-color-hover: var(--status-color-success); 79 | --sl-input-border-color-focus: var(--status-color-success); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/styles/components/_kbd.scss: -------------------------------------------------------------------------------- 1 | @mixin kbd() { 2 | // We only target non-nested kbd elements 3 | kbd:not(:has(kbd)) { 4 | position: relative; 5 | display: inline-block; 6 | min-width: var(--spacing-xl); 7 | min-height: var(--spacing-xxl); 8 | padding: 0 var(--spacing-r-lg); 9 | font-family: sans-serif; 10 | line-height: var(--line-height-xxl); 11 | vertical-align: middle; 12 | color: var(--text-color-secondary); 13 | text-align: center; 14 | text-shadow: 0 1px 0 var(--octonary-contrast); 15 | border: 1px solid var(--quinary-contrast); 16 | border-radius: var(--border-radius-sm); 17 | box-shadow: 18 | 0 1px 0 rgb(0 0 0 / 20%), 19 | 0 0 0 2px var(--octonary-contrast) inset; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/styles/components/_links.scss: -------------------------------------------------------------------------------- 1 | @mixin links() { 2 | a { 3 | transition: color 0.3s ease; 4 | } 5 | 6 | p a { 7 | text-decoration: underline; 8 | } 9 | 10 | p > a, 11 | td > a, 12 | div > a, 13 | code > a, 14 | li a { 15 | color: var(--links-color); 16 | 17 | &:hover { 18 | color: var(--links-color-hover); 19 | } 20 | 21 | &:active { 22 | color: var(--links-color-active); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/styles/components/_loaders.scss: -------------------------------------------------------------------------------- 1 | @mixin loaders() { 2 | .loading__image { 3 | animation: spin 1s linear infinite; 4 | } 5 | } 6 | 7 | @keyframes spin { 8 | to { 9 | transform: rotate(360deg); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/styles/components/_options.scss: -------------------------------------------------------------------------------- 1 | @mixin options() { 2 | sl-option { 3 | &.sl-option--primary { 4 | &::part(base) { 5 | padding: var(--spacing-r-sm); 6 | color: var(--options-color); 7 | background: var(--options-background); 8 | 9 | &:hover { 10 | background: var(--options-background-hover); 11 | transition: background 0.3s ease; 12 | } 13 | } 14 | 15 | &::part(label) { 16 | font-size: var(--font-size-sm); 17 | } 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/styles/components/_pages.scss: -------------------------------------------------------------------------------- 1 | @mixin read-page() { 2 | box-sizing: border-box; 3 | display: flex; 4 | flex-direction: column; 5 | justify-self: center; 6 | width: 100%; 7 | max-width: var(--page-width); 8 | padding: var(--layout-padding); 9 | } 10 | -------------------------------------------------------------------------------- /src/styles/components/_selects.scss: -------------------------------------------------------------------------------- 1 | $loading-icon-suffix-width: 30px; 2 | 3 | @mixin selects() { 4 | sl-select.select--primary { 5 | --sl-input-background-color: var(--inputs-background-color); 6 | --sl-input-background-color-hover: var(--inputs-background-color-hover); 7 | --sl-input-background-color-focus: var(--inputs-background-color-focus); 8 | --sl-input-border-color: var(--inputs-border-color); 9 | --sl-input-border-color-hover: var(--inputs-border-color-hover); 10 | --sl-input-border-color-focus: var(--inputs-border-color-focus); 11 | --sl-input-font-family: var(--inter-font); 12 | --sl-input-font-size-medium: var(--font-size-md); 13 | --sl-input-color: var(--inputs-color); 14 | --sl-input-color-hover: var(--inputs-color-hover); 15 | --sl-input-color-focus: var(--inputs-color-focus); 16 | --sl-input-placeholder-color: var(--inputs-placeholder-color); 17 | --sl-input-focus-ring-color: var(--inputs-focus-ring-color); 18 | --sl-input-focus-ring-offset: 0; 19 | --sl-input-required-content: ''; 20 | --sl-input-required-content-offset: 0; 21 | --sl-panel-background-color: var(--panels-background); 22 | 23 | &::part(base) { 24 | transition: 25 | color 0.3s ease, 26 | background-color 0.3s ease, 27 | border-color 0.3s ease; 28 | } 29 | 30 | &::part(input)::placeholder { 31 | font-size: var(--font-size-sm); 32 | } 33 | 34 | &::part(form-control) { 35 | text-align: start; 36 | } 37 | 38 | &::part(form-control-label) { 39 | margin-block-end: var(--spacing-r-sm); 40 | } 41 | 42 | &::part(form-control-help-text) { 43 | margin-top: var(--spacing-r-sm); 44 | font-size: var(--font-size-xs); 45 | color: var(--inputs-placeholder-color); 46 | } 47 | 48 | .loading__image { 49 | width: $loading-icon-suffix-width; 50 | height: auto; 51 | margin-inline-end: var(--spacing-r-sm); 52 | } 53 | 54 | sl-icon { 55 | transition: color 0.3s ease; 56 | } 57 | } 58 | 59 | sl-input:not([disabled]):hover sl-icon { 60 | color: var(--icons-color-hover); 61 | } 62 | 63 | sl-input.ng-invalid.ng-touched:not(form) { 64 | &::part(form-control-input) { 65 | --sl-input-border-color: var(--status-color-error); 66 | --sl-input-border-color-hover: var(--status-color-error); 67 | --sl-input-border-color-focus: var(--status-color-error); 68 | --sl-input-placeholder-color: var(--status-color-error); 69 | } 70 | 71 | &::part(form-control-help-text) { 72 | color: var(--status-color-error); 73 | } 74 | } 75 | 76 | sl-input.ng-valid.ng-touched:not(form) { 77 | &::part(form-control-input) { 78 | --sl-input-border-color: var(--status-color-success); 79 | --sl-input-border-color-hover: var(--status-color-success); 80 | --sl-input-border-color-focus: var(--status-color-success); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/styles/global.scss: -------------------------------------------------------------------------------- 1 | @use 'base/reset'; 2 | @use 'base/z-index'; 3 | @use 'base/border-radius'; 4 | @use 'base/spacing'; 5 | @use 'base/typography'; 6 | @use 'base/themes'; 7 | @use 'components/headings'; 8 | @use 'components/alerts'; 9 | @use 'components/links'; 10 | @use 'components/dropdowns'; 11 | @use 'components/buttons'; 12 | @use 'components/forms'; 13 | @use 'components/inputs'; 14 | @use 'components/selects'; 15 | @use 'components/options'; 16 | @use 'components/checkboxes'; 17 | @use 'components/kbd'; 18 | @use 'components/loaders'; 19 | 20 | // Base 21 | @include reset.reset; 22 | @include typography.typography; 23 | @include themes.themes; 24 | 25 | // Components 26 | @include headings.headings; 27 | @include alerts.alerts; 28 | @include links.links; 29 | @include buttons.buttons; 30 | @include dropdowns.dropdowns; 31 | @include forms.forms; 32 | @include inputs.inputs; 33 | @include selects.selects; 34 | @include options.options; 35 | @include checkboxes.checkboxes; 36 | @include kbd.kbd; 37 | @include loaders.loaders; 38 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | /* To learn more about this file see: https://angular.io/config/tsconfig. */ 2 | { 3 | "extends": "./tsconfig.json", 4 | "compilerOptions": { 5 | "outDir": "./out-tsc/app", 6 | "types": ["@angular/localize"] 7 | }, 8 | "files": ["src/main.ts"], 9 | "include": ["src/**/*.d.ts"] 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": ["src/**/*.*", "e2e/**/*.*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "compileOnSave": false, 5 | "compilerOptions": { 6 | "baseUrl": "./", 7 | "outDir": "./dist/out-tsc", 8 | "strict": true, 9 | "noImplicitOverride": true, 10 | "noPropertyAccessFromIndexSignature": true, 11 | "noImplicitReturns": true, 12 | "noFallthroughCasesInSwitch": true, 13 | "skipLibCheck": true, 14 | "isolatedModules": true, 15 | "esModuleInterop": true, 16 | "sourceMap": true, 17 | "declaration": false, 18 | "experimentalDecorators": true, 19 | "moduleResolution": "bundler", 20 | "importHelpers": true, 21 | "target": "ES2022", 22 | "module": "ES2022", 23 | "lib": ["ES2022", "dom"], 24 | "paths": { 25 | "~environments/*": ["src/environments/*"], 26 | "~core/*": ["src/app/core/*"], 27 | "~features/*": ["src/app/features/*"] 28 | } 29 | }, 30 | "angularCompilerOptions": { 31 | "enableI18nLegacyMessageIdFormat": false, 32 | "strictInjectionParameters": true, 33 | "strictInputAccessModifiers": true, 34 | "strictTemplates": true, 35 | "strictStandalone": true 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | /* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */ 2 | /* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */ 3 | { 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "outDir": "./out-tsc/spec", 7 | "types": ["jasmine", "@angular/localize"] 8 | }, 9 | "include": ["src/**/*.spec.ts", "src/**/*.d.ts"] 10 | } 11 | --------------------------------------------------------------------------------