Frontend
The frontend was built using Vue.js. The frontend component of RefArch was utilized for this purpose.
Prerequisites
- Node.js 22 LTS (
22.11.x-22.x.x) - Docker (for AppSwitcher)
Structure
The frontend component is structured into different folders with distinct functions.
API
This folder contains all functionalities related to API requests to the backend. This includes the client generated from the Open-Api specification. The client can be easily regenerated with the following command:
openapi-generator-cli generateNo additional library was used for the generation. The generated client is based on standard fetch functions.
Additionally, this folder contains functions for requesting:
userinfoendpoint of the SSOinfoendpoint of the Spring backendhealthcheckendpoint to check the status of the ApiGateway
Components
This folder contains all components created in Vue.js for the web application. All components are divided into general categories based on their usage. At the top level, we have the AdDialog for creating new ads or editing existing ones, and the AdList for displaying ads found through a search query. Additionally, there is the AdNavBar, which primarily includes all filter and sorting options, and the SnackbarQueue for displaying messages to the end user.
For the navigation bar, all individual components are located in the Filter folder. The common folder contains general components such as a simple YesNoDialog or the AccountCard.
For the admin page (admin), the settings options are divided into individual components. It is noteworthy that a new system setting can be easily added by updating the OpenApi specification and adding a "Row" in GeneralSettings. You only need to choose the type of setting - File, Number, TextSelect, or Text. Currently, there is no component for a Boolean setting, as it has not been required so far.
All components related to an ad are further divided into three display modes:
- Details: Detailed view showing all possible parameters and specified options.
- Dialog: Editable fields for creating and editing an ad.
- List: Reduced view showing only the most necessary information within a list.
Composables
Vue.js describes reusable components as composables. These are stored in this folder. Generally, all API requests are wrapped with composables. The approach was inspired by TenStack. Each composable provides a call function and dynamic refs to the received data, any errors that may have occurred, and whether the current request is still running. They are categorized according to the respective controllers in the backend.
The composable useSnackbar wraps the use of the built-in snackbar. To send a message to the user, simply call the composable:
snackbar.sendMessage({
level: Levels.INFO,
message: "Some Msg",
});Additionally, all event buses used here are registered in useEventBus. An event bus can thus be easily imported and used as a constant.
With useDownloadFile, files can be downloaded via browser functionality. This includes, for example, the terms and conditions file or attached files in an ad.
Locales
The web application uses i18n, which is actually intended for localization or offering different languages. Currently, only one language (German) is offered. The tool (i18n) serves to outsource all texts of the application into a common file (as long as none were forgotten).
Plugins
This folder contains all plugins used in Vue.js, each configured in individual files. The routing information is described in router, as well as the theme used by Vuetify in vuetify.
For the aforementioned localization and future-proofing, the configuration is in i18n. This also includes the ability to dynamically change the language throughout the application via function.
Stores
This web application utilizes state management through Pinia Store. All used stores are located in different files. The stores provide various access methods to the data contained within them. Some stores are populated with data in "special" ways - this includes, for example, the ads themselves.
Types
This folder contains "custom types," i.e., TypeScript classes or interfaces that are needed beyond the classes generated by the client.
Util
Functionalities that are needed in multiple places are outsourced to this folder. Specifically for this application, a mapping for the sorting dropdown is stored here.
Views
The views are the individual pages that can be directly accessed in the web application via a path.
- AdminPage (/admin): Describes the admin page with options for adjusting categories and other system settings.
- BoardPage (/): Essentially the homepage, which includes the listing of ads and general sorting options for them.
- DetailsPage (/ad): Shows the detailed information of an ad with a large display of the image and contact information.
Multi‑Build Variants
The project supports building different variants of the frontend from the same code base. This was useful because two products shared most functionality but had minor differences (for example, App A vs. App B). Each variant ended up in its own subfolder under dist and could be served via its own base path.
Environment Files
Separate .env files were created for each variant. Vite loads them based on the build mode. Each file defines at least VITE_APP_VARIANT:
# .env.appA
VITE_APP_VARIANT=appA
# .env.appB
VITE_APP_VARIANT=appBOther environment variables like VITE_AD2IMAGE_URL could also be overridden. The variant flag determines output paths and which files are used for variant‑specific components. Folder names are therefore bound to the variants name.
Vite Configuration
vite.config.ts exports a function that derives settings based on the current mode and environment variables. Key steps included:
loadEnv(mode, process.cwd(), '')was used to read the.env.<mode>file and cast it to anImportMetaEnvtype so TypeScript knows the variable names.- The variant name was extracted from
VITE_APP_VARIANT, falling back to the mode name. From this value variantDir(./src/variants/<variant>), outDir(dist/<variant>)and basePath(/<variant>/)were derived. - Aliases were defined:
@points to src and@variantspoints to the current variant directory. Vite then resolves@variants/components/...to the correct variant directory. - ''emptyOutDir'' was disabled so that multiple builds could write into dist without removing each other.
The resulting configuration looked like this:
import { defineConfig, loadEnv } from 'vite';
import vue from '@vitejs/plugin-vue';
import vuetify from 'vite-plugin-vuetify';
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '') as ImportMetaEnv;
const variant = env.VITE_APP_VARIANT || (mode as 'appA' | 'appB');
return {
base: env.VITE_BASE_PATH || `/${variant}/`,
plugins: [vue(), vuetify()],
resolve: {
alias: {
"@variants": fileURLToPath(
new URL(`./src/variants/${variant}`, import.meta.url)
),
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
...
},
build: {
outDir: `dist/${variant}`,
emptyOutDir: false,
minify: true,
},
...
};
});Package Scripts
To build both variants in one command, modify the build script in package.json to call Vite twice:
"scripts": {
"build": "vite build --mode appA && vite build --mode appB",
"dev:appA": "vite --mode appA",
...
}CI pipelines that rely on the build script name do not need to change.
Variant‑Specific Components
Most code is shared across variants. For differences, place files under src/variants/<variant>. In shared components, import the variant‑specific version using the alias:
<script
setup
lang="ts"
>
import AdPriceSelection from "@variants/components/VariantSpecificComponent.vue";
// ...
</script>For example, if App B should not allow price entry, create src/variants/appB/components/VariantSpecificComponent.vue with a stub that always emits 0. A simple implementation uses v‑model and emits update:modelValue in onMounted:
<script
setup
lang="ts"
>
import { onMounted } from "vue";
const props = defineProps<{ modelValue: number | null }>();
const emit = defineEmits<{ (e: "update:modelValue", value: number): void }>();
onMounted(() => {
if (props.modelValue !== 0) {
emit("update:modelValue", 0);
}
});
</script>
<template></template>If only a small section differs, extract it into its own component or composable. Keep the larger parent component in src/components and inject the variant‑dependent part via the alias. This avoids duplicating entire files.
TypeScript and ESLint
Because TypeScript and ESLint do not know about Vite aliases, add them to tsconfig.app.json:
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@variants/*": ["./src/variants/appA/*", "./src/variants/appB/*"]
}
}List each variant folder so the tools can resolve imports for any variant. This prevents import/no-unresolved errors.
Running the Dev Server
By default, npm run dev uses the dev script defined in package.json. Use --mode <variant> to start the server for a specific variant:
npm run dev -- --mode appA # start App A
npm run dev -- --mode appB # start App BAlternatively, set VITE_APP_VARIANT in your shell before running the dev command. Vite picks it up via the environment.
Variant switching at deployment time
Both variants remain in the Docker image after the multi‑build. During local development it makes sense to access them via their respective sub‑paths so that you can switch quickly. For deployment, you can, however, serve one variant at the root URL while keeping the other hidden. This is achieved by a rewrite in the API gateway: the catch‑all route (Path=/**) receives a RewritePath filter that forwards all requests to the desired sub‑path. An example for the appA variant looks like this:
SPRING_CLOUD_GATEWAY_ROUTES_2_ID=frontend
SPRING_CLOUD_GATEWAY_ROUTES_2_URI=http://host.docker.internal:8081/
SPRING_CLOUD_GATEWAY_ROUTES_2_PREDICATES_0=Path=/**
SPRING_CLOUD_GATEWAY_ROUTES_2_FILTERS_0=RewritePath=/(?<path>.*), /appA/${path}As a result http://localhost:8083/ serves the built appA application even though its Vite base path remains /appA/. To switch to the other variant you only change the prefix in the gateway configuration. API calls should continue to use absolute paths so they are not affected by the rewrite.