├── .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 |
4 | Skip to main content
5 |
6 |
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 |
17 |
18 |
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 |
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 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | @if (!menuOpen()) {
12 |
13 | }
14 |
15 |
27 |
28 |
29 |
30 |
42 |
43 |
44 | @if (!isUserLoggedIn()) {
45 |
57 |
58 |
59 |
71 |
72 |
89 |
90 |
93 |
94 |
97 | } @else {
98 |
99 |
111 |
112 |
113 |
116 |
117 |
118 |
119 |
120 |
121 |
130 |
131 |
132 |
143 |
144 |
145 | Log out
146 |
147 |
148 |
149 |
150 |
151 | }
152 |
153 |
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 |
76 |
77 |
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 |
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 |
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 |
109 |
110 |
111 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------