Skip to content

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:

shell
openapi-generator-cli generate

No additional library was used for the generation. The generated client is based on standard fetch functions.

Additionally, this folder contains functions for requesting:

  • userinfo endpoint of the SSO
  • info endpoint of the Spring backend
  • healthcheck endpoint 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:

typescript
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:

test
# .env.appA
VITE_APP_VARIANT=appA

# .env.appB
VITE_APP_VARIANT=appB

Other 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 an ImportMetaEnv type 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 @variants points 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:

ts
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:

json
"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:

html
<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:

html
<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:

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:

shell
npm run dev -- --mode appA  # start App A
npm run dev -- --mode appB  # start App B

Alternatively, 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:

properties
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.