├── public
├── robots.txt
├── favicon.ico
├── logo192.png
├── logo512.png
├── manifest.json
└── index.html
├── src
├── images
│ └── planet.png
├── styles
│ ├── myProfile.css
│ ├── Missions.css
│ ├── MissionsList.css
│ ├── Rockets.css
│ └── App.css
├── setupTests.js
├── __test__
│ ├── missionSlice.test.js
│ ├── rocketsActions.test.js
│ ├── MissionsList.test.js
│ ├── MyProfile.test.js
│ ├── rocketsSlice.test.js
│ └── Rockets.test.js
├── redux
│ ├── store.js
│ ├── missions
│ │ └── missionsSlice.js
│ └── rockets
│ │ ├── rocketsActions.js
│ │ └── rocketsSlice.js
├── reportWebVitals.js
├── index.css
├── index.js
├── components
│ ├── Missions.js
│ ├── MissionsList.js
│ ├── MyProfile.js
│ └── Rockets.js
├── App.js
└── logo.svg
├── .babelrc
├── .gitignore
├── .stylelintrc.json
├── .eslintrc.json
├── LICENSE
├── .github
└── workflows
│ └── linters.yml
├── package.json
└── README.md
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ClaudiaRojasSoto/Space_Travelers/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ClaudiaRojasSoto/Space_Travelers/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ClaudiaRojasSoto/Space_Travelers/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/src/images/planet.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/ClaudiaRojasSoto/Space_Travelers/HEAD/src/images/planet.png
--------------------------------------------------------------------------------
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-react"
4 | ],
5 | "plugins": ["@babel/plugin-syntax-jsx"]
6 | }
--------------------------------------------------------------------------------
/src/styles/myProfile.css:
--------------------------------------------------------------------------------
1 | .myProfile {
2 | display: grid;
3 | grid-template-columns: 1fr 1fr;
4 | width: 80%;
5 | margin: 0 auto;
6 | text-align: center;
7 | font-size: 1.5rem;
8 | gap: 1rem;
9 | }
10 |
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom';
6 |
--------------------------------------------------------------------------------
/src/__test__/missionSlice.test.js:
--------------------------------------------------------------------------------
1 | import missionsReducer, { fetchMissions } from '../redux/missions/missionsSlice';
2 |
3 | test('reducers', () => {
4 | let state = null;
5 | state = missionsReducer(undefined, fetchMissions.pending());
6 | expect(state.isLoading).toBe(true);
7 | });
8 |
--------------------------------------------------------------------------------
/src/redux/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import missionsReducer from './missions/missionsSlice';
3 | import rocketsReducer from './rockets/rocketsSlice';
4 |
5 | const store = configureStore({
6 | reducer: {
7 | rockets: rocketsReducer,
8 | missions: missionsReducer,
9 | },
10 | });
11 |
12 | export default store;
13 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env.local
17 | .env.development.local
18 | .env.test.local
19 | .env.production.local
20 |
21 | npm-debug.log*
22 | yarn-debug.log*
23 | yarn-error.log*
24 |
--------------------------------------------------------------------------------
/src/reportWebVitals.js:
--------------------------------------------------------------------------------
1 | const reportWebVitals = (onPerfEntry) => {
2 | if (onPerfEntry && onPerfEntry instanceof Function) {
3 | import('web-vitals').then(({
4 | getCLS, getFID, getFCP, getLCP, getTTFB,
5 | }) => {
6 | getCLS(onPerfEntry);
7 | getFID(onPerfEntry);
8 | getFCP(onPerfEntry);
9 | getLCP(onPerfEntry);
10 | getTTFB(onPerfEntry);
11 | });
12 | }
13 | };
14 |
15 | export default reportWebVitals;
16 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | box-sizing: border-box;
3 | margin: 0;
4 | padding: 0;
5 | }
6 |
7 | body {
8 | margin: 0;
9 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
10 | -webkit-font-smoothing: antialiased;
11 | -moz-osx-font-smoothing: grayscale;
12 | }
13 |
14 | code {
15 | font-family: 'Source Code Pro', Menlo, Monaco, Consolas, 'Courier New', monospace;
16 | }
17 |
--------------------------------------------------------------------------------
/src/styles/Missions.css:
--------------------------------------------------------------------------------
1 | .status {
2 | padding: 0.5rem;
3 | border-radius: 0.5rem;
4 | margin: 0 1rem;
5 | }
6 |
7 | .leaveBtn {
8 | color: red;
9 | border: 1px solid red;
10 | min-width: 135px;
11 | }
12 |
13 | .joinBtn {
14 | color: #6c757c;
15 | border: 1px solid #6c757c;
16 | min-width: 135px;
17 | }
18 |
19 | .notActive {
20 | min-width: 135px;
21 | color: #fff;
22 | background-color: #6c757c;
23 | border-radius: 0.5rem;
24 | }
25 |
26 | .activeMember {
27 | min-width: 145px;
28 | color: #fff;
29 | background-color: #18a3b9;
30 | }
31 |
--------------------------------------------------------------------------------
/src/styles/MissionsList.css:
--------------------------------------------------------------------------------
1 | .table {
2 | margin: 1rem auto;
3 | border-collapse: separate;
4 | border-spacing: 0;
5 | width: 80%;
6 | }
7 |
8 | th,
9 | td {
10 | padding: 0.3rem;
11 | border: 1px solid #e4e9eb;
12 | }
13 |
14 | th {
15 | text-align: left;
16 | }
17 |
18 | tr {
19 | margin: 0.4rem;
20 | }
21 |
22 | td:nth-child(1) {
23 | font-weight: bolder;
24 | font-size: 1.2rem;
25 | padding: 0.3rem;
26 | }
27 |
28 | td:nth-child(2) {
29 | padding: 0.3rem;
30 | }
31 |
32 | tbody tr:nth-child(odd) {
33 | background-color: #f2f3f3;
34 | }
35 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom/client';
3 | import './index.css';
4 | import App from './App';
5 | import reportWebVitals from './reportWebVitals';
6 |
7 | const root = ReactDOM.createRoot(document.getElementById('root'));
8 | root.render(
9 |
10 |
11 | ,
12 | );
13 |
14 | // If you want to start measuring performance in your app, pass a function
15 | // to log results (for example: reportWebVitals(console.log))
16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
17 | reportWebVitals();
18 |
--------------------------------------------------------------------------------
/.stylelintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["stylelint-config-standard"],
3 | "plugins": ["stylelint-scss", "stylelint-csstree-validator"],
4 | "rules": {
5 | "at-rule-no-unknown": [
6 | true,
7 | {
8 | "ignoreAtRules": ["tailwind", "apply", "variants", "responsive", "screen"]
9 | }
10 | ],
11 | "scss/at-rule-no-unknown": [
12 | true,
13 | {
14 | "ignoreAtRules": ["tailwind", "apply", "variants", "responsive", "screen"]
15 | }
16 | ],
17 | "csstree/validator": true
18 | },
19 | "ignoreFiles": ["build/**", "dist/**", "**/reset*.css", "**/bootstrap*.css", "**/*.js", "**/*.jsx"]
20 | }
--------------------------------------------------------------------------------
/src/styles/Rockets.css:
--------------------------------------------------------------------------------
1 | .container {
2 | display: flex;
3 | flex-direction: column;
4 | gap: 3rem;
5 | padding: 0 3rem 6rem;
6 | }
7 |
8 | .rocket-item {
9 | display: flex;
10 | gap: 2rem;
11 | background-color: #ffffff1a;
12 | padding-right: 1.2rem;
13 | overflow: hidden;
14 | }
15 |
16 | .rocket-item img {
17 | width: 350px;
18 | }
19 |
20 | .rocket-details {
21 | display: flex;
22 | flex-direction: column;
23 | gap: 1.8rem;
24 | padding-top: 1rem;
25 | }
26 |
27 | .rocket-details-name {
28 | font-size: 2rem;
29 | font-weight: 500;
30 | }
31 |
32 | .rocket-details-description {
33 | font-weight: 400;
34 | line-height: 1.3;
35 | font-size: 2rem;
36 | }
37 |
--------------------------------------------------------------------------------
/src/styles/App.css:
--------------------------------------------------------------------------------
1 | .header {
2 | margin: 1rem 4rem 2rem 4rem;
3 | display: flex;
4 | justify-content: space-between;
5 | padding-bottom: 2rem;
6 | border-bottom: 1px solid;
7 | }
8 |
9 | .header h1 {
10 | font-size: 3rem;
11 | }
12 |
13 | img {
14 | width: 4rem;
15 | }
16 |
17 | .header div {
18 | display: flex;
19 | justify-content: center;
20 | align-items: center;
21 | gap: 1rem;
22 | }
23 |
24 | nav ul {
25 | width: 100%;
26 | display: flex;
27 | gap: 2rem;
28 | margin-top: 1.5rem;
29 | }
30 |
31 | li {
32 | list-style: none;
33 | }
34 |
35 | .nav-item:nth-child(2) {
36 | border-right: 3px solid #121212;
37 | padding-right: 2rem;
38 | }
39 |
40 | a {
41 | font-size: 1.5rem;
42 | text-decoration: none;
43 | color: rgb(40, 40, 252);
44 | }
45 |
46 | .active {
47 | text-decoration: underline;
48 | color: rgb(49, 49, 250);
49 | font-weight: bolder;
50 | }
51 |
--------------------------------------------------------------------------------
/src/__test__/rocketsActions.test.js:
--------------------------------------------------------------------------------
1 | import * as rocketsActions from '../redux/rockets/rocketsActions';
2 |
3 | describe('rocketsActions', () => {
4 | it('should create an action to set rockets data', () => {
5 | const mockData = [{
6 | id: 1, name: 'Rocket Name 1', type: 'Rocket Type 1', flickr_images: ['image1_1', 'image1_2'], reserved: false,
7 | }];
8 | const expectedAction = {
9 | type: 'SET_ROCKETS_DATA',
10 | payload: mockData,
11 | };
12 | expect(rocketsActions.setRocketsData(mockData)).toEqual(expectedAction);
13 | });
14 |
15 | it('should create an action to set loading', () => {
16 | const expectedAction = {
17 | type: 'SET_ROCKETS_LOADING',
18 | };
19 | expect(rocketsActions.setLoading()).toEqual(expectedAction);
20 | });
21 |
22 | it('should create an action to set error', () => {
23 | const error = 'Error fetching data';
24 | const expectedAction = {
25 | type: 'SET_ROCKETS_ERROR',
26 | payload: error,
27 | };
28 | expect(rocketsActions.setError(error)).toEqual(expectedAction);
29 | });
30 | });
31 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es6": true,
5 | "jest": true
6 | },
7 | "parser": "@babel/eslint-parser",
8 | "parserOptions": {
9 | "ecmaFeatures": {
10 | "jsx": true
11 | },
12 | "ecmaVersion": 2018,
13 | "sourceType": "module"
14 | },
15 | "extends": ["airbnb", "plugin:react/recommended", "plugin:react-hooks/recommended"],
16 | "plugins": ["react"],
17 | "rules": {
18 | "react/jsx-filename-extension": ["warn", { "extensions": [".js", ".jsx"] }],
19 | "react/react-in-jsx-scope": "off",
20 | "import/no-unresolved": "off",
21 | "no-shadow": "off"
22 | },
23 | "overrides": [
24 | {
25 | // feel free to replace with your preferred file pattern - eg. 'src/**/*Slice.js' or 'redux/**/*Slice.js'
26 | "files": ["src/**/*Slice.js"],
27 | // avoid state param assignment
28 | "rules": { "no-param-reassign": ["error", { "props": false }] }
29 | }
30 | ],
31 | "ignorePatterns": [
32 | "dist/",
33 | "build/"
34 | ]
35 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Claudia Rojas
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/components/Missions.js:
--------------------------------------------------------------------------------
1 | import PropTypes from 'prop-types';
2 | import { useDispatch } from 'react-redux';
3 | import { member } from '../redux/missions/missionsSlice';
4 | import '../styles/Missions.css';
5 |
6 | function Missions({
7 | name, description, id, activeMember,
8 | }) {
9 | const dispatch = useDispatch();
10 | return (
11 |
12 | {name}
13 | {description}
14 |
15 | {activeMember ? 'ACTIVE MEMBER' : 'NOT A MEMBER'}
16 |
17 |
18 | {
22 | dispatch(member(id));
23 | }}
24 | >
25 | {activeMember ? 'Leave Mission' : 'Join Mission'}
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | Missions.propTypes = {
33 | name: PropTypes.string.isRequired,
34 | description: PropTypes.string.isRequired,
35 | id: PropTypes.string.isRequired,
36 | activeMember: PropTypes.bool.isRequired,
37 | };
38 |
39 | export default Missions;
40 |
--------------------------------------------------------------------------------
/src/components/MissionsList.js:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 | import { useDispatch, useSelector } from 'react-redux';
3 | import Missions from './Missions';
4 | import { fetchMissions } from '../redux/missions/missionsSlice';
5 | import '../styles/MissionsList.css';
6 |
7 | function MissionsList() {
8 | const { missions } = useSelector((state) => state.missions);
9 | const dispatch = useDispatch();
10 |
11 | useEffect(() => {
12 | if (missions.length === 0) dispatch(fetchMissions());
13 | }, [missions.length, dispatch]);
14 |
15 | return (
16 |
17 |
18 |
19 | Mission
20 | Description
21 | Status
22 |
23 |
24 |
25 | {missions.map((mission) => (
26 |
33 | ))}
34 |
35 |
36 | );
37 | }
38 |
39 | export default MissionsList;
40 |
--------------------------------------------------------------------------------
/src/__test__/MissionsList.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import configureStore from 'redux-mock-store';
3 | import { Provider } from 'react-redux';
4 | import '@testing-library/jest-dom';
5 | import MissionsList from '../components/MissionsList';
6 |
7 | const mockStore = configureStore([]);
8 |
9 | describe('Missions', () => {
10 | let store;
11 | beforeEach(() => {
12 | store = mockStore({
13 | missions: {
14 | missions: [
15 | {
16 | mission_id: '1000',
17 | mission_name: 'first mission',
18 | description: 'first description',
19 | activeMember: true,
20 | },
21 | {
22 | mission_id: '2000',
23 | mission_name: 'second mission',
24 | description: 'second description',
25 | activeMember: false,
26 | },
27 | ],
28 | },
29 | });
30 | });
31 |
32 | test('renders mission list correctly', () => {
33 | render(
34 |
35 |
36 | ,
37 | );
38 |
39 | expect(screen.getByText('first mission')).toBeInTheDocument();
40 | expect(screen.getByText('second mission')).toBeInTheDocument();
41 |
42 | expect(screen.getByText('second description')).toBeInTheDocument();
43 | expect(screen.getByText('second description')).toBeInTheDocument();
44 |
45 | expect(screen.getByText('ACTIVE MEMBER')).toBeInTheDocument();
46 | expect(screen.getByText('NOT A MEMBER')).toBeInTheDocument();
47 | });
48 | });
49 |
--------------------------------------------------------------------------------
/src/redux/missions/missionsSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
2 | import axios from 'axios';
3 |
4 | const initialState = {
5 | isLoading: false,
6 | missions: [],
7 | error: undefined,
8 | joined: false,
9 | };
10 |
11 | const fetchMissions = createAsyncThunk('missions/fetchMissions', async () => {
12 | try {
13 | const response = await axios.get(
14 | 'https://api.spacexdata.com/v3/missions',
15 | );
16 | return response.data;
17 | } catch (error) {
18 | throw error.response;
19 | }
20 | });
21 |
22 | const missionsSlice = createSlice({
23 | name: 'missions',
24 | initialState,
25 | reducers: {
26 | member: (state, action) => {
27 | const missionId = action.payload;
28 | state.missions = state.missions.map((mission) => (mission.mission_id === missionId
29 | ? { ...mission, activeMember: !mission?.activeMember ?? true }
30 | : mission));
31 | },
32 | },
33 | extraReducers: (builder) => {
34 | builder
35 | .addCase(fetchMissions.pending, (state) => {
36 | state.isLoading = true;
37 | state.error = undefined;
38 | })
39 | .addCase(fetchMissions.fulfilled, (state, action) => {
40 | state.isLoading = false;
41 | state.missions = action.payload;
42 | })
43 | .addCase(fetchMissions.rejected, (state, action) => {
44 | state.isLoading = false;
45 | state.error = action.error.message;
46 | });
47 | },
48 | });
49 |
50 | export default missionsSlice.reducer;
51 | export { fetchMissions };
52 | export const { member } = missionsSlice.actions;
53 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { Provider } from 'react-redux';
3 | import {
4 | BrowserRouter as Router, Route, Routes, NavLink,
5 | } from 'react-router-dom';
6 | import Rockets from './components/Rockets';
7 | import MissionsList from './components/MissionsList';
8 | import MyProfile from './components/MyProfile';
9 | import './styles/App.css';
10 | import planet from './images/planet.png';
11 | import store from './redux/store';
12 |
13 | function App() {
14 | return (
15 |
16 |
17 |
18 |
19 |
20 |
Space Traveler's Hub
21 |
22 |
23 |
24 |
25 |
26 | Rockets
27 |
28 |
29 |
30 |
31 | Missions
32 |
33 |
34 |
35 |
36 | My Profile
37 |
38 |
39 |
40 |
41 |
42 |
43 | } />
44 | } />
45 | } />
46 |
47 |
48 |
49 | );
50 | }
51 |
52 | export default App;
53 |
--------------------------------------------------------------------------------
/src/__test__/MyProfile.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render, waitFor } from '@testing-library/react';
3 | import { Provider } from 'react-redux';
4 | import configureStore from 'redux-mock-store';
5 | import thunk from 'redux-thunk';
6 | import MyProfile from '../components/MyProfile';
7 |
8 | const mockStore = configureStore([thunk]);
9 |
10 | describe('MyProfile', () => {
11 | it('displays loading state initially', () => {
12 | const store = mockStore({
13 | rockets: { rocketsData: [] },
14 | missions: { missions: [] },
15 | });
16 |
17 | const { getByText } = render(
18 |
19 |
20 | ,
21 | );
22 |
23 | expect(getByText('Loading...')).toBeInTheDocument();
24 | });
25 |
26 | it('displays missions and rockets when data is loaded', async () => {
27 | const store = mockStore({
28 | rockets: {
29 | rocketsData: [
30 | { id: 1, rocket_name: 'Falcon 1', reserved: true },
31 | { id: 2, rocket_name: 'Falcon Heavy', reserved: false },
32 | ],
33 | },
34 | missions: {
35 | missions: [
36 | { mission_id: 1, mission_name: 'Starlink-1', activeMember: true },
37 | { mission_id: 2, mission_name: 'Starlink-2', activeMember: false },
38 | ],
39 | },
40 | });
41 |
42 | const { getByText } = render(
43 |
44 |
45 | ,
46 | );
47 |
48 | await waitFor(() => {
49 | expect(getByText('My Missions')).toBeInTheDocument();
50 | expect(getByText('Starlink-1')).toBeInTheDocument();
51 | expect(getByText('My Rockets')).toBeInTheDocument();
52 | expect(getByText('Falcon 1')).toBeInTheDocument();
53 | });
54 | });
55 | });
56 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
27 | React App
28 |
29 |
30 | You need to enable JavaScript to run this app.
31 |
32 |
42 |
43 |
44 |
--------------------------------------------------------------------------------
/src/redux/rockets/rocketsActions.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export const setRocketsData = (rocketsData) => ({
4 | type: 'SET_ROCKETS_DATA',
5 | payload: rocketsData,
6 | });
7 |
8 | export const setLoading = () => ({
9 | type: 'SET_ROCKETS_LOADING',
10 | });
11 |
12 | export const setError = (error) => ({
13 | type: 'SET_ROCKETS_ERROR',
14 | payload: error,
15 | });
16 |
17 | export const reserveRocket = (rocketId) => (dispatch, getState) => {
18 | dispatch({
19 | type: 'RESERVE_ROCKET',
20 | payload: rocketId,
21 | });
22 |
23 | const updatedRocketsData = getState().rockets.rocketsData;
24 | localStorage.setItem('rocketsData', JSON.stringify(updatedRocketsData));
25 | };
26 |
27 | export const cancelRocket = (rocketId) => (dispatch, getState) => {
28 | dispatch({
29 | type: 'CANCEL_ROCKET',
30 | payload: rocketId,
31 | });
32 |
33 | const updatedRocketsData = getState().rockets.rocketsData;
34 | localStorage.setItem('rocketsData', JSON.stringify(updatedRocketsData));
35 | };
36 |
37 | export const fetchRocketsData = () => (dispatch) => {
38 | dispatch(setLoading());
39 |
40 | const savedRocketsData = localStorage.getItem('rocketsData');
41 |
42 | if (savedRocketsData) {
43 | dispatch(setRocketsData(JSON.parse(savedRocketsData)));
44 | } else {
45 | axios.get('https://api.spacexdata.com/v3/rockets')
46 | .then((response) => {
47 | const rocketsData = response.data.map((rocket) => ({
48 | id: rocket.id,
49 | name: rocket.rocket_name,
50 | type: rocket.rocket_type,
51 | flickr_images: rocket.flickr_images,
52 | reserved: false,
53 | }));
54 |
55 | localStorage.setItem('rocketsData', JSON.stringify(rocketsData));
56 |
57 | dispatch(setRocketsData(rocketsData));
58 | })
59 | .catch((error) => {
60 | dispatch(setError(error.message));
61 | });
62 | }
63 | };
64 |
--------------------------------------------------------------------------------
/.github/workflows/linters.yml:
--------------------------------------------------------------------------------
1 | name: Linters
2 |
3 | on: pull_request
4 |
5 | env:
6 | FORCE_COLOR: 1
7 |
8 | jobs:
9 | eslint:
10 | name: ESLint
11 | runs-on: ubuntu-22.04
12 | steps:
13 | - uses: actions/checkout@v3
14 | - uses: actions/setup-node@v3
15 | with:
16 | node-version: "18.x"
17 | - name: Setup ESLint
18 | run: |
19 | npm install --save-dev eslint@7.x eslint-config-airbnb@18.x eslint-plugin-import@2.x eslint-plugin-jsx-a11y@6.x eslint-plugin-react@7.x eslint-plugin-react-hooks@4.x @babel/eslint-parser@7.x @babel/core@7.x @babel/plugin-syntax-jsx@7.x @babel/preset-env@7.x @babel/preset-react@7.x
20 | [ -f .eslintrc.json ] || wget https://raw.githubusercontent.com/microverseinc/linters-config/master/react-redux/.eslintrc.json
21 | [ -f .babelrc ] || wget https://raw.githubusercontent.com/microverseinc/linters-config/master/react-redux/.babelrc
22 | - name: ESLint Report
23 | run: npx eslint "**/*.{js,jsx}"
24 | stylelint:
25 | name: Stylelint
26 | runs-on: ubuntu-22.04
27 | steps:
28 | - uses: actions/checkout@v3
29 | - uses: actions/setup-node@v3
30 | with:
31 | node-version: "18.x"
32 | - name: Setup Stylelint
33 | run: |
34 | npm install --save-dev stylelint@13.x stylelint-scss@3.x stylelint-config-standard@21.x stylelint-csstree-validator@1.x
35 | [ -f .stylelintrc.json ] || wget https://raw.githubusercontent.com/microverseinc/linters-config/master/react-redux/.stylelintrc.json
36 | - name: Stylelint Report
37 | run: npx stylelint "**/*.{css,scss}"
38 | nodechecker:
39 | name: node_modules checker
40 | runs-on: ubuntu-22.04
41 | steps:
42 | - uses: actions/checkout@v3
43 | - name: Check node_modules existence
44 | run: |
45 | if [ -d "node_modules/" ]; then echo -e "\e[1;31mThe node_modules/ folder was pushed to the repo. Please remove it from the GitHub repository and try again."; echo -e "\e[1;32mYou can set up a .gitignore file with this folder included on it to prevent this from happening in the future." && exit 1; fi
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "space_travelers",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@reduxjs/toolkit": "^1.9.5",
7 | "@testing-library/jest-dom": "^5.17.0",
8 | "@testing-library/react": "^13.4.0",
9 | "@testing-library/user-event": "^13.5.0",
10 | "bootstrap": "^5.3.0",
11 | "localforage": "^1.10.0",
12 | "prop-types": "^15.8.1",
13 | "react": "^18.2.0",
14 | "react-bootstrap": "^2.8.0",
15 | "react-dom": "^18.2.0",
16 | "react-redux": "^8.1.1",
17 | "react-router-dom": "^6.14.2",
18 | "react-scripts": "5.0.1",
19 | "redux": "^4.2.1",
20 | "redux-logger": "^3.0.6",
21 | "redux-thunk": "^2.4.2",
22 | "web-vitals": "^2.1.4",
23 | "axios": "^1.4.0"
24 | },
25 | "scripts": {
26 | "start": "react-scripts start",
27 | "build": "react-scripts build",
28 | "test": "react-scripts test",
29 | "eject": "react-scripts eject"
30 | },
31 | "eslintConfig": {
32 | "extends": [
33 | "react-app",
34 | "react-app/jest"
35 | ]
36 | },
37 | "browserslist": {
38 | "production": [
39 | ">0.2%",
40 | "not dead",
41 | "not op_mini all"
42 | ],
43 | "development": [
44 | "last 1 chrome version",
45 | "last 1 firefox version",
46 | "last 1 safari version"
47 | ]
48 | },
49 | "devDependencies": {
50 | "@babel/core": "^7.22.9",
51 | "@babel/eslint-parser": "^7.22.9",
52 | "@babel/plugin-proposal-private-property-in-object": "^7.21.11",
53 | "@babel/plugin-syntax-jsx": "^7.22.5",
54 | "@babel/preset-react": "^7.22.5",
55 | "axios-mock-adapter": "^1.21.5",
56 | "eslint": "^7.32.0",
57 | "eslint-config-airbnb": "^18.2.1",
58 | "eslint-plugin-import": "^2.27.5",
59 | "eslint-plugin-jsx-a11y": "^6.7.1",
60 | "eslint-plugin-react": "^7.33.0",
61 | "eslint-plugin-react-hooks": "^4.6.0",
62 | "jest-mock": "^29.6.1",
63 | "redux-mock-store": "^1.5.4",
64 | "stylelint": "^13.13.1",
65 | "stylelint-config-standard": "^21.0.0",
66 | "stylelint-csstree-validator": "^1.9.0",
67 | "stylelint-scss": "^3.21.0"
68 | },
69 | "jest": {
70 | "transformIgnorePatterns": [
71 | "node_modules/(?!axios)/"
72 | ]
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/src/components/MyProfile.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import '../styles/myProfile.css';
4 | import { fetchRocketsData } from '../redux/rockets/rocketsSlice';
5 | import { fetchMissions } from '../redux/missions/missionsSlice';
6 |
7 | const MyProfile = () => {
8 | const dispatch = useDispatch();
9 | const [loaded, setLoaded] = useState(false);
10 | const rocketsData = useSelector((state) => state.rockets.rocketsData);
11 | const reservedRockets = rocketsData.filter((rocket) => rocket.reserved);
12 | const { missions } = useSelector((state) => state.missions);
13 | const activeMissions = missions.filter((active) => active.activeMember);
14 |
15 | useEffect(() => {
16 | if (rocketsData.length === 0) {
17 | dispatch(fetchMissions());
18 | dispatch(fetchRocketsData()).then(() => setLoaded(true));
19 | } else {
20 | setLoaded(true);
21 | }
22 | }, [dispatch, rocketsData]);
23 |
24 | if (!loaded) {
25 | return Loading...
;
26 | }
27 |
28 | return (
29 |
30 |
31 |
My Missions
32 |
33 |
34 | {activeMissions.map((missions) => (
35 |
36 | {missions.mission_name}
37 |
38 | ))}
39 |
40 |
41 |
42 |
43 |
My Rockets
44 |
45 |
46 | {reservedRockets.map((rocket) => (
47 |
48 | {rocket.rocket_name}
49 |
50 | ))}
51 |
52 |
53 |
54 |
55 | );
56 | };
57 |
58 | export default MyProfile;
59 |
--------------------------------------------------------------------------------
/src/__test__/rocketsSlice.test.js:
--------------------------------------------------------------------------------
1 | import rocketsReducer, {
2 | setRocketsData, setLoading, setError, reserveRocket, cancelRocket,
3 | } from '../redux/rockets/rocketsSlice';
4 |
5 | describe('rockets reducer', () => {
6 | let state = null;
7 |
8 | beforeEach(() => {
9 | state = {
10 | rocketsData: [],
11 | loading: false,
12 | error: null,
13 | };
14 | });
15 |
16 | it('should handle setRocketsData', () => {
17 | const mockData = [
18 | {
19 | id: 1, rocket_name: 'Rocket Name 1', description: 'Description 1', flickr_images: ['image1_1', 'image1_2'], reserved: false,
20 | },
21 | {
22 | id: 2, rocket_name: 'Rocket Name 2', description: 'Description 2', flickr_images: ['image2_1', 'image2_2'], reserved: false,
23 | },
24 | ];
25 |
26 | state = rocketsReducer(state, setRocketsData(mockData));
27 | expect(state.rocketsData).toEqual(mockData);
28 | expect(state.loading).toBe(false);
29 | expect(state.error).toBe(null);
30 | });
31 |
32 | it('should handle setLoading', () => {
33 | state = rocketsReducer(state, setLoading());
34 | expect(state.loading).toBe(true);
35 | expect(state.error).toBe(null);
36 | });
37 |
38 | it('should handle setError', () => {
39 | const mockError = 'Error fetching data';
40 |
41 | state = rocketsReducer(state, setError(mockError));
42 | expect(state.loading).toBe(false);
43 | expect(state.error).toEqual(mockError);
44 | });
45 |
46 | it('should handle reserveRocket', () => {
47 | const initialState = {
48 | rocketsData: [{
49 | id: 1, rocket_name: 'Rocket Name 1', description: 'Description 1', flickr_images: ['image1_1', 'image1_2'], reserved: false,
50 | }],
51 | loading: false,
52 | error: null,
53 | };
54 |
55 | state = rocketsReducer(initialState, reserveRocket(1));
56 | expect(state.rocketsData[0].reserved).toBe(true);
57 | });
58 |
59 | it('should handle cancelRocket', () => {
60 | const initialState = {
61 | rocketsData: [{
62 | id: 1, rocket_name: 'Rocket Name 1', description: 'Description 1', flickr_images: ['image1_1', 'image1_2'], reserved: true,
63 | }],
64 | loading: false,
65 | error: null,
66 | };
67 |
68 | state = rocketsReducer(initialState, cancelRocket(1));
69 | expect(state.rocketsData[0].reserved).toBe(false);
70 | });
71 | });
72 |
--------------------------------------------------------------------------------
/src/redux/rockets/rocketsSlice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import axios from 'axios';
3 | import localforage from 'localforage';
4 |
5 | const initialState = {
6 | rocketsData: [],
7 | loading: false,
8 | error: null,
9 | };
10 |
11 | const rocketsSlice = createSlice({
12 | name: 'rockets',
13 | initialState,
14 | reducers: {
15 | setRocketsData: (state, action) => {
16 | state.rocketsData = action.payload;
17 | state.loading = false;
18 | state.error = null;
19 | },
20 | setLoading: (state) => {
21 | state.loading = true;
22 | state.error = null;
23 | },
24 | setError: (state, action) => {
25 | state.loading = false;
26 | state.error = action.payload;
27 | },
28 | reserveRocket: (state, action) => {
29 | const id = action.payload;
30 | state.rocketsData = state.rocketsData.map((rocket) => {
31 | if (rocket.id === id) {
32 | return { ...rocket, reserved: true };
33 | }
34 | return rocket;
35 | });
36 | localforage.setItem('rockets', state.rocketsData);
37 | },
38 | cancelRocket: (state, action) => {
39 | const id = action.payload;
40 | state.rocketsData = state.rocketsData.map((rocket) => {
41 | if (rocket.id === id) {
42 | return { ...rocket, reserved: false };
43 | }
44 | return rocket;
45 | });
46 | localforage.setItem('rockets', state.rocketsData);
47 | },
48 | },
49 | });
50 |
51 | export const fetchRocketsData = () => async (dispatch) => {
52 | dispatch(rocketsSlice.actions.setLoading());
53 | try {
54 | let rocketsData = await localforage.getItem('rockets');
55 | if (!rocketsData) {
56 | const response = await axios.get('https://api.spacexdata.com/v3/rockets');
57 | rocketsData = response.data.map((rocket) => ({
58 | id: rocket.id,
59 | rocket_name: rocket.rocket_name,
60 | description: rocket.description,
61 | flickr_images: rocket.flickr_images,
62 | }));
63 | }
64 | dispatch(rocketsSlice.actions.setRocketsData(rocketsData));
65 | } catch (error) {
66 | dispatch(rocketsSlice.actions.setError(error.message));
67 | }
68 | };
69 |
70 | export const {
71 | setRocketsData, setLoading, setError, reserveRocket, cancelRocket,
72 | } = rocketsSlice.actions;
73 |
74 | export default rocketsSlice.reducer;
75 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/components/Rockets.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect } from 'react';
2 | import { useSelector, useDispatch } from 'react-redux';
3 | import { fetchRocketsData, reserveRocket, cancelRocket } from '../redux/rockets/rocketsSlice';
4 | import '../styles/Rockets.css';
5 |
6 | const Rockets = () => {
7 | const dispatch = useDispatch();
8 | const loading = useSelector((state) => state.rockets.loading);
9 | const error = useSelector((state) => state.rockets.error);
10 | const rocketsData = useSelector((state) => state.rockets.rocketsData);
11 |
12 | useEffect(() => {
13 | if (rocketsData.length === 0) {
14 | dispatch(fetchRocketsData());
15 | }
16 | }, [dispatch, rocketsData]);
17 |
18 | const handleButtonClick = (rocket) => {
19 | if (rocket.reserved) {
20 | dispatch(cancelRocket(rocket.id));
21 | } else {
22 | dispatch(reserveRocket(rocket.id));
23 | }
24 | };
25 |
26 | if (loading) {
27 | return Loading...
;
28 | }
29 |
30 | if (error) {
31 | return (
32 |
33 | Error:
34 | {' '}
35 | {error}
36 |
37 | );
38 | }
39 |
40 | return (
41 |
42 | {rocketsData.map((rocket) => (
43 |
44 | {rocket.flickr_images && (
45 |
46 | )}
47 |
48 |
{rocket.rocket_name}
49 |
50 | {rocket.reserved ? (
51 |
55 | Reserved
56 |
57 | ) : null}
58 | {' '}
59 | {rocket.description}
60 |
61 |
handleButtonClick(rocket)}
64 | style={{
65 | backgroundColor: rocket.reserved ? 'white' : '#027bff',
66 | fontSize: '18px',
67 | color: rocket.reserved ? '#333333' : 'white',
68 | width: rocket.reserved ? '200px' : '180px',
69 | height: '50px',
70 | padding: '10px',
71 | borderRadius: '5px',
72 | border: rocket.reserved ? '2px solid grey' : 'none',
73 | }}
74 | >
75 | {rocket.reserved ? 'Cancel Reservation' : 'Reserve Rocket'}
76 |
77 |
78 |
79 | ))}
80 |
81 | );
82 | };
83 |
84 | export default Rockets;
85 |
--------------------------------------------------------------------------------
/src/__test__/Rockets.test.js:
--------------------------------------------------------------------------------
1 | import { render, screen } from '@testing-library/react';
2 | import configureStore from 'redux-mock-store';
3 | import { Provider } from 'react-redux';
4 | import '@testing-library/jest-dom';
5 | import Rockets from '../components/Rockets';
6 |
7 | const mockStore = configureStore([]);
8 |
9 | describe('Rockets', () => {
10 | let store;
11 | beforeEach(() => {
12 | store = mockStore({
13 | rockets: {
14 | loading: false,
15 | error: null,
16 | rocketsData: [
17 | {
18 | id: '1000',
19 | rocket_name: 'first rocket',
20 | description: 'first description',
21 | reserved: true,
22 | flickr_images: ['http://example.com/image1.jpg'],
23 | },
24 | {
25 | id: '2000',
26 | rocket_name: 'second rocket',
27 | description: 'second description',
28 | reserved: false,
29 | flickr_images: ['http://example.com/image2.jpg'],
30 | },
31 | ],
32 | },
33 | });
34 | });
35 |
36 | test('renders rockets list correctly', () => {
37 | render(
38 |
39 |
40 | ,
41 | );
42 |
43 | expect(screen.getByText('first rocket')).toBeInTheDocument();
44 | expect(screen.getByText('second rocket')).toBeInTheDocument();
45 |
46 | expect(screen.getByText('first description')).toBeInTheDocument();
47 | expect(screen.getByText('second description')).toBeInTheDocument();
48 |
49 | expect(screen.getByText('Reserved')).toBeInTheDocument();
50 | expect(screen.getByRole('button', { name: /Reserve Rocket/i })).toBeInTheDocument();
51 | });
52 |
53 | test('renders the image for each rocket', () => {
54 | render(
55 |
56 |
57 | ,
58 | );
59 |
60 | const firstRocketImage = document.querySelector('img[src="http://example.com/image1.jpg"]');
61 | const secondRocketImage = document.querySelector('img[src="http://example.com/image2.jpg"]');
62 |
63 | expect(firstRocketImage).toBeInTheDocument();
64 | expect(secondRocketImage).toBeInTheDocument();
65 | });
66 |
67 | test('renders "Reserve Rocket" button for unreserved rockets', () => {
68 | render(
69 |
70 |
71 | ,
72 | );
73 |
74 | const reserveButton = screen.getByRole('button', { name: /Reserve Rocket/i });
75 |
76 | expect(reserveButton).toBeInTheDocument();
77 | });
78 |
79 | test('renders "Cancel Reservation" button for reserved rockets', () => {
80 | render(
81 |
82 |
83 | ,
84 | );
85 |
86 | const cancelButton = screen.getByRole('button', { name: /Cancel Reservation/i });
87 |
88 | expect(cancelButton).toBeInTheDocument();
89 | });
90 |
91 | test('does not render "Loading..." when not loading', () => {
92 | render(
93 |
94 |
95 | ,
96 | );
97 |
98 | expect(screen.queryByText('Loading...')).not.toBeInTheDocument();
99 | });
100 |
101 | test('does not render error when there is no error', () => {
102 | render(
103 |
104 |
105 | ,
106 | );
107 |
108 | expect(screen.queryByText(/Error:/i)).not.toBeInTheDocument();
109 | });
110 | });
111 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | Space Traveler's Hub
2 |
3 |
4 |
5 |
6 |
7 | # 📗 Table of Contents
8 |
9 | - [📗 Table of Contents](#-table-of-contents)
10 | - [📖 space-travelers-capstone](#-space-travelers-capstone)
11 | - [🛠 Built With ](#-built-with-)
12 | - [Tech Stack ](#tech-stack-)
13 | - [Key Features ](#key-features-)
14 | - [🚀 Live Demo ](#-live-demo-)
15 | - [💻 Getting Started ](#-getting-started-)
16 | - [Prerequisites](#prerequisites)
17 | - [Setup](#setup)
18 | - [Install](#install)
19 | - [Usage](#usage)
20 | - [Run tests](#run-tests)
21 | - [Deployment](#deployment)
22 | - [👥 Author ](#-author-)
23 | - [🔭 Future Features ](#-future-features-)
24 | - [Walkthrough ](#walkthrough-)
25 | - [🤝 Contributing ](#-contributing-)
26 | - [⭐️ Show your support ](#️-show-your-support-)
27 | - [🙏 Acknowledgments ](#-acknowledgments-)
28 | - [📝 License ](#-license-)
29 |
30 |
31 |
32 | # 📖 Space Traveler's Hub
33 |
34 | The space-travelers-capstone is a Web application for a company that provides commercial and scientific space travel services. The application will allow users to book rockets and join selected space missions.
35 |
36 |
37 | ## 🛠 Built With
38 |
39 | ### Tech Stack
40 |
41 | - HTML
42 | - JS
43 | - CSS
44 | - React
45 | - Redux Toolkit (RTK)
46 |
47 |
48 | Client
49 | - HTML
50 | - JS
51 | - CSS
52 | - React
53 |
54 |
55 |
56 | Server
57 | - Null
58 |
59 |
60 |
61 | Database
62 | - Null
63 |
64 |
65 | ### Key Features
66 |
67 | - **Space Travelers Capstone using React**
68 | - **Client-side routing using React Router V6**
69 | - **Gitflow is used correctly**
70 | - **Work is documented in a professional manner**
71 | - **Following best practices for HTML, CSS, JS**
72 |
73 |
74 |
75 |
76 |
77 |
78 | [SpaceTravelers.webm](https://github.com/ClaudiaRojasSoto/Space_Travelers/assets/111262493/c6a3917d-a45b-4818-9d94-191a8192c06c)
79 | ## 🚀 Live Demo
80 |
81 | > You can see the live demo of this project: [click here](https://space-travelers-z8vp.onrender.com)
82 |
83 |
84 |
85 | (back to top )
86 |
87 |
88 |
89 | ## 💻 Getting Started
90 |
91 | To get a local copy up and running, follow these steps.
92 |
93 | ### Prerequisites
94 |
95 | - A web browser
96 | - A code editor
97 | - A terminal
98 |
99 | ### Setup
100 |
101 | Clone this repository to your desired folder:
102 |
103 | ```sh
104 | git clone https://github.com/ClaudiaRojasSoto/Space_Travelers.git
105 | ```
106 |
107 | ### Install
108 |
109 | Install this project with:
110 |
111 | ```sh
112 | cd space-travelers-capstone
113 | npm install
114 | ```
115 |
116 | ### Usage
117 |
118 | To run the project on the webpack dev server, execute the following command:
119 |
120 | ```sh
121 | npm start
122 | ```
123 |
124 | ### Run tests
125 |
126 | To run tests, run the following command:
127 |
128 |
129 | ```sh
130 | npm test
131 | ```
132 |
133 | ### Deployment
134 |
135 | ```sh
136 | npm run deploy
137 | ```
138 |
139 | (back to top )
140 |
141 |
142 |
143 | ## 👥 Author
144 |
145 | 👤 Claudia Rojas
146 |
147 | - GitHub: [@ClaudiaRojasSoto](https://github.com/ClaudiaRojasSoto)
148 | - LinkedIn: [claudia-rojas-soto](https://www.linkedin.com/in/claudia-rojas-soto)
149 |
150 | 👤 César Herrera
151 |
152 | - GitHub: [@cesarherr](https://github.com/Cesarherr)
153 | - Twitter: [@cesarherr2](https://twitter.com/cesarherr2)
154 | - LinkedIn: [cesarherr](https://www.linkedin.com/in/cesarherr/)
155 |
156 | ## 🔭 Future Features
157 |
158 | - Add dragons Section
159 |
160 | ## Walkthrough
161 |
162 | In this project, we will be copying a given web design using React, Redux, and API handling. The design will serve as a reference for implementing the user interface and interactions.
163 | Please see the above sections if you want to copy and setup this project on your pc.
164 |
165 |
166 |
167 | ## 🤝 Contributing
168 |
169 | Contributions, issues, and feature requests are welcome!
170 |
171 | (back to top )
172 |
173 |
174 |
175 | ## ⭐️ Show your support
176 |
177 | If you like this project, give it a ⭐️!
178 |
179 | (back to top )
180 |
181 | ## 🙏 Acknowledgments
182 |
183 | We would like to thank Microverse for giving us the opportunity to learn and grow as developers and also we like to thank our families, they are all our support. 🌟
184 |
185 | (back to top )
186 |
187 |
188 |
189 | ## 📝 License
190 |
191 | This project is [MIT](./LICENSE) licensed.
192 |
193 | (back to top )
194 |
--------------------------------------------------------------------------------