├── 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 | 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 | 20 | 21 | 22 | 23 | 24 | 25 | {missions.map((mission) => ( 26 | 33 | ))} 34 | 35 |
MissionDescriptionStatus
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 | logo 20 |

Space Traveler's Hub

21 |
22 | 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 | 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 | 37 | 38 | ))} 39 | 40 |
{missions.mission_name}
41 |
42 |
43 |

My Rockets

44 | 45 | 46 | {reservedRockets.map((rocket) => ( 47 | 48 | 49 | 50 | ))} 51 | 52 |
{rocket.rocket_name}
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 | 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 | --------------------------------------------------------------------------------