From 9f14b2e78f141d5a9d1ee256e51ad5ff4b94c855 Mon Sep 17 00:00:00 2001 From: Wildan Alfandi Date: Thu, 14 May 2026 10:55:44 +0700 Subject: [PATCH] feat: implement SpaceX rocket list and detail with filter, add, and components --- README.md | 100 +- components.d.ts | 17 + package-lock.json | 4893 ++++++++++++++++++++++++++++ package.json | 1 + src/components/AddRocketDialog.vue | 156 + src/components/ErrorState.vue | 33 + src/components/HelloWorld.vue | 157 - src/components/LoadingState.vue | 16 + src/components/README.md | 35 - src/components/RocketCard.vue | 82 + src/pages/README.md | 5 - src/pages/index.vue | 100 +- src/pages/rockets/[id].vue | 135 + src/plugins/README.md | 3 - src/plugins/index.ts | 21 +- src/plugins/vuetify.ts | 16 +- src/router/index.ts | 34 +- src/stores/rocket.ts | 112 + src/styles/README.md | 3 - 19 files changed, 5642 insertions(+), 277 deletions(-) create mode 100644 components.d.ts create mode 100644 package-lock.json create mode 100644 src/components/AddRocketDialog.vue create mode 100644 src/components/ErrorState.vue delete mode 100644 src/components/HelloWorld.vue create mode 100644 src/components/LoadingState.vue delete mode 100644 src/components/README.md create mode 100644 src/components/RocketCard.vue delete mode 100644 src/pages/README.md create mode 100644 src/pages/rockets/[id].vue delete mode 100644 src/plugins/README.md create mode 100644 src/stores/rocket.ts delete mode 100644 src/styles/README.md diff --git a/README.md b/README.md index 6a8b00d..bcc6dc4 100644 --- a/README.md +++ b/README.md @@ -1,36 +1,82 @@ -# Allo Bank Frontend Technical Assignment +# Allo SpaceX — Frontend Test -In this assignment, you’re assigned to create a website that displays rockets. This website only has two screens: rocket list screen and rocket detail screen. Here are the requirements: +A small Vue 3 application that lists SpaceX rockets, lets the user filter and add new entries, and shows a detail page for each rocket. -### Functional Requirements -- As a user, I want to see a list of rockets in the rocket list screen (Show each rocket image, rocket name, and rocket description) -- As a user, I want to be able to filter the rockets in the rocket list screen -- As a user, I want to be able to add the new rocket in the rocket list screen -- As a user, I want to be able to see the rocket detail by clicking a rocket in the rocket list screen (Show rocket image, rocket name, rocket description, cost per launch, country, first flight) +## Tech Stack -### Non-Functional Requirements -- Use Space-X API (https://github.com/r-spacex/SpaceX-API) for getting the rocket data -- Implement routers -- Implement state management -- Implement lifecycles -- Create components based will be + points -- UI states (Loading, Fail/Retry, and Success) -- Show loading when waiting response from API -- If an error occurred, user can retry by pressing retry button -- Show result when get response from API +| Layer | Choice | Reason | +| --- | --- | --- | +| Framework | **Vue 3 (Composition API)** | Reactive primitives, ` diff --git a/src/components/ErrorState.vue b/src/components/ErrorState.vue new file mode 100644 index 0000000..d03b807 --- /dev/null +++ b/src/components/ErrorState.vue @@ -0,0 +1,33 @@ + + + diff --git a/src/components/HelloWorld.vue b/src/components/HelloWorld.vue deleted file mode 100644 index 4ff10db..0000000 --- a/src/components/HelloWorld.vue +++ /dev/null @@ -1,157 +0,0 @@ - - - diff --git a/src/components/LoadingState.vue b/src/components/LoadingState.vue new file mode 100644 index 0000000..4745ea4 --- /dev/null +++ b/src/components/LoadingState.vue @@ -0,0 +1,16 @@ + + + diff --git a/src/components/README.md b/src/components/README.md deleted file mode 100644 index d1dc92f..0000000 --- a/src/components/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Components - -Vue template files in this folder are automatically imported. - -## 🚀 Usage - -Importing is handled by [unplugin-vue-components](https://github.com/unplugin/unplugin-vue-components). This plugin automatically imports `.vue` files created in the `src/components` directory, and registers them as global components. This means that you can use any component in your application without having to manually import it. - -The following example assumes a component located at `src/components/MyComponent.vue`: - -```vue - - - -``` - -When your template is rendered, the component's import will automatically be inlined, which renders to this: - -```vue - - - -``` diff --git a/src/components/RocketCard.vue b/src/components/RocketCard.vue new file mode 100644 index 0000000..fda5aad --- /dev/null +++ b/src/components/RocketCard.vue @@ -0,0 +1,82 @@ + + + + + diff --git a/src/pages/README.md b/src/pages/README.md deleted file mode 100644 index 341536c..0000000 --- a/src/pages/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# Pages - -Vue components created in this folder will automatically be converted to navigatable routes. - -Full documentation for this feature can be found in the Official [unplugin-vue-router](https://github.com/posva/unplugin-vue-router) repository. diff --git a/src/pages/index.vue b/src/pages/index.vue index dac59c7..fe2909c 100644 --- a/src/pages/index.vue +++ b/src/pages/index.vue @@ -1,7 +1,101 @@ - diff --git a/src/pages/rockets/[id].vue b/src/pages/rockets/[id].vue new file mode 100644 index 0000000..58e37f7 --- /dev/null +++ b/src/pages/rockets/[id].vue @@ -0,0 +1,135 @@ + + + diff --git a/src/plugins/README.md b/src/plugins/README.md deleted file mode 100644 index 62201c7..0000000 --- a/src/plugins/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Plugins - -Plugins are a way to extend the functionality of your Vue application. Use this folder for registering plugins that you want to use globally. diff --git a/src/plugins/index.ts b/src/plugins/index.ts index d3c748a..d585911 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,18 +1,13 @@ -/** - * plugins/index.ts - * - * Automatically included in `./src/main.ts` - */ - // Plugins -import vuetify from './vuetify' -import router from '../router' +import vuetify from "./vuetify"; +import router from "../router"; +import { createPinia } from "pinia"; // Types -import type { App } from 'vue' +import type { App } from "vue"; + +const pinia = createPinia(); -export function registerPlugins (app: App) { - app - .use(vuetify) - .use(router) +export function registerPlugins(app: App) { + app.use(vuetify).use(router).use(pinia); } diff --git a/src/plugins/vuetify.ts b/src/plugins/vuetify.ts index 7652788..688efe0 100644 --- a/src/plugins/vuetify.ts +++ b/src/plugins/vuetify.ts @@ -1,19 +1,13 @@ -/** - * plugins/vuetify.ts - * - * Framework documentation: https://vuetifyjs.com` - */ - // Styles -import '@mdi/font/css/materialdesignicons.css' -import 'vuetify/styles' +import "@mdi/font/css/materialdesignicons.css"; +import "vuetify/styles"; // Composables -import { createVuetify } from 'vuetify' +import { createVuetify } from "vuetify"; // https://vuetifyjs.com/en/introduction/why-vuetify/#feature-guides export default createVuetify({ theme: { - defaultTheme: 'dark', + defaultTheme: "dark", }, -}) +}); diff --git a/src/router/index.ts b/src/router/index.ts index aeab4c3..a73f86b 100644 --- a/src/router/index.ts +++ b/src/router/index.ts @@ -1,35 +1,29 @@ -/** - * router/index.ts - * - * Automatic routes for `./src/pages/*.vue` - */ - // Composables -import { createRouter, createWebHistory } from 'vue-router/auto' -import { routes } from 'vue-router/auto-routes' +import { createRouter, createWebHistory } from "vue-router/auto"; +import { routes } from "vue-router/auto-routes"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), routes, -}) +}); // Workaround for https://github.com/vitejs/vite/issues/11804 router.onError((err, to) => { - if (err?.message?.includes?.('Failed to fetch dynamically imported module')) { - if (!localStorage.getItem('vuetify:dynamic-reload')) { - console.log('Reloading page to fix dynamic import error') - localStorage.setItem('vuetify:dynamic-reload', 'true') - location.assign(to.fullPath) + if (err?.message?.includes?.("Failed to fetch dynamically imported module")) { + if (!localStorage.getItem("vuetify:dynamic-reload")) { + console.log("Reloading page to fix dynamic import error"); + localStorage.setItem("vuetify:dynamic-reload", "true"); + location.assign(to.fullPath); } else { - console.error('Dynamic import error, reloading page did not fix it', err) + console.error("Dynamic import error, reloading page did not fix it", err); } } else { - console.error(err) + console.error(err); } -}) +}); router.isReady().then(() => { - localStorage.removeItem('vuetify:dynamic-reload') -}) + localStorage.removeItem("vuetify:dynamic-reload"); +}); -export default router +export default router; diff --git a/src/stores/rocket.ts b/src/stores/rocket.ts new file mode 100644 index 0000000..862091f --- /dev/null +++ b/src/stores/rocket.ts @@ -0,0 +1,112 @@ +import { defineStore } from "pinia"; +import { ref } from "vue"; + +export interface Rocket { + id: string; + name: string; + description: string; + flickr_images: string[]; + cost_per_launch: number; + country: string; + first_flight: string; + active: boolean; +} + +const SPACEX_API_BASE = "https://api.spacexdata.com/v4/rockets"; + +export const useRocketStore = defineStore("rocket", () => { + // List state + const rockets = ref([]); + const filteredRockets = ref([]); + const searchQuery = ref(""); + const loading = ref(false); + const error = ref(null); + + const selectedRocket = ref(null); + const detailLoading = ref(false); + const detailError = ref(null); + + async function fetchRockets() { + loading.value = true; + error.value = null; + try { + const response = await fetch(SPACEX_API_BASE); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + const data: Rocket[] = await response.json(); + rockets.value = data; + applyFilter(); + } catch (err) { + console.error("fetchRockets failed:", err); + error.value = "Failed to load rockets. Please try again."; + } finally { + loading.value = false; + } + } + + async function fetchRocketById(id: string) { + detailLoading.value = true; + detailError.value = null; + selectedRocket.value = null; + try { + const local = rockets.value.find((r) => r.id === id); + if (local) { + selectedRocket.value = local; + return; + } + + const response = await fetch(`${SPACEX_API_BASE}/${id}`); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + selectedRocket.value = await response.json(); + } catch (err) { + console.error("fetchRocketById failed:", err); + detailError.value = "Failed to load rocket detail. Please try again."; + } finally { + detailLoading.value = false; + } + } + + function applyFilter() { + const q = searchQuery.value.trim().toLowerCase(); + if (!q) { + filteredRockets.value = rockets.value; + return; + } + filteredRockets.value = rockets.value.filter( + (r) => + r.name.toLowerCase().includes(q) || + r.description.toLowerCase().includes(q), + ); + } + + function filterRockets(query: string) { + searchQuery.value = query; + applyFilter(); + } + + function addRocket(rocket: Rocket) { + rockets.value.push(rocket); + applyFilter(); + } + + return { + // List + rockets, + filteredRockets, + searchQuery, + loading, + error, + // Detail + selectedRocket, + detailLoading, + detailError, + // Actions + fetchRockets, + fetchRocketById, + filterRockets, + addRocket, + }; +}); diff --git a/src/styles/README.md b/src/styles/README.md deleted file mode 100644 index ea86179..0000000 --- a/src/styles/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Styles - -This directory is for configuring the styles of the application.