A multi-step travel visa application wizard built with Nuxt 4. Users select citizenship and destination countries (powered by the REST Countries API), enter personal details, review their application, and submit it to a session list.
Live Demo: https://visa-wizard-eta.vercel.app/
- Searchable country dropdowns with flag, name, and capital display
- Fuzzy country search (e.g.
Grmanymatches Germany) - Reusable
FormInputcomponent for all text, email, tel, number, and date fields - Shared
CountryDropdownPanel+ custom scrollbar for country and phone-prefix lists - Three-step wizard with free navigation and inline validation
- Phone calling-code prefix derived from citizenship country - updates automatically when the phone field is blank; if a number is already entered, the prefix is kept and an orange warning is shown when citizenship (and its dialing code) changes, until the user edits the phone or prefix
- Review screen with country region, population, and languages
- Submitted applications list with status badges (click to cycle status)
- Session persistence via
localStorage(lean records only - see privacy note below) - Step transition animations
Submitted applications are stored in the browser’s localStorage under visa_applications for demo convenience. Only a lean record is persisted: applicant display name, citizenship/destination summary (name, flags, codes, region, population), submission date, and status. Email, phone, date of birth, and passport number are not written to storage. This is still client-side data on a shared machine - do not use real passport details in production demos.
- Node.js 20+ (22 recommended - matches the Docker image)
- npm 9+
- A REST Countries API key (
NUXT_REST_COUNTRIES_API_KEY)
-
Clone the repository
git clone https://github.com/web-master00/visa-wizard.git cd visa-wizard -
Install dependencies
npm install
-
Configure environment variables
Copy the example file and add your API key:
cp .env.example .env
NUXT_REST_COUNTRIES_API_KEY=your_api_key_here
-
Start the development server
npm run dev
The app runs at http://localhost:3000.
| Command | Description |
|---|---|
npm run dev |
Start the Nuxt dev server |
npm run build |
Build for production |
npm run preview |
Preview the production build locally |
npm test |
Run unit tests (Vitest) |
npm run typecheck |
Run Nuxt / Vue TypeScript checks |
npm run lint |
Alias for typecheck |
Run the app in a container with Docker Compose:
# Set your API key in .env first, then:
docker compose up --buildThe app is served at http://localhost:3000.
The Dockerfile uses a two-stage build (builder → runner) on node:22-alpine, copies only the .output bundle into the final image, and runs as a non-root nuxt user.
npm run build
node .output/server/index.mjsSet NUXT_REST_COUNTRIES_API_KEY in the environment of the host or process manager.
Country data is fetched through a Nitro route at server/api/countries.get.ts instead of calling the REST Countries API directly from the browser. This keeps the API key on the server, normalizes the v5 API response into a consistent Country shape, and caches the full country list in memory (1-hour TTL) after the first request.
Wizard state lives in useVisaWizard and country data in useCountries. In the running app, useCountries uses Nuxt useFetch (keyed countries-list). Outside a component instance (unit tests), it falls back to a module-level fetch cache. Wizard form state uses module-level reactive refs so step navigation does not reset filled-in data. Submitted applications are persisted to localStorage (lean shape) and reloaded on mount.
Per the assignment constraints, no pre-built UI library (Vuetify, Element Plus, etc.) is used. All components - including the searchable CountrySelect dropdown with keyboard navigation and a custom scrollbar - are built with Vue 3 and Tailwind CSS v4 (@tailwindcss/vite). Design tokens live in app/assets/css/tailwind.css (@theme).
All text-based form fields are consolidated into a single FormInput component (app/components/FormInput.vue) instead of duplicating raw <input> markup and styles across the wizard. It supports text, email, tel, number, and date input types, and exposes three visual variants:
| Variant | Used for |
|---|---|
default |
Standard labelled fields (name, email, passport, date of birth) with optional error state |
search |
Compact search inputs inside country dropdowns |
embedded |
Borderless input nested inside composite controls (e.g. phone number beside the calling-code selector) |
The component handles optional label/error rendering, date-picker calendar icon, uppercase passport formatting, and muted placeholder styling for empty date fields. It uses defineModel for two-way binding and exposes a focus() method so parent dropdowns can auto-focus search inputs on open.
Used in PersonalDetailsForm.vue (full name, email, phone, date, passport) and CountryDropdownPanel.vue (country search).
Form rules are implemented in app/utils/visaValidator.ts as plain functions (validateStep1, validateStep2). The composable calls these on step advance and submit, surfacing inline error messages in each form field.
useCountries implements a subsequence-matching fuzzyMatch helper (with Unicode normalization) so partial or mistyped queries like Untd Stats still surface the correct country. This avoids pulling in a separate search library.
app.vue renders the active step with a dynamic <component :is="..."> and a fade transition (mode="out-in") for smooth step changes. StepProgress allows clicking back to completed steps while validating forward navigation.
Unit tests use Vitest with happy-dom and @vue/test-utils. Coverage includes visaValidator utilities, applicationStorage, the useCountries composable, and the StepProgress component.
app/
├── app.vue # Root layout, step orchestration
├── components/
│ ├── AppIcon.vue # Inline SVG icon helper
│ ├── FormInput.vue # Reusable input (default / search / embedded variants)
│ ├── CountryDropdownPanel.vue # Shared searchable list + custom scrollbar
│ ├── CountrySelect.vue # Searchable country dropdown
│ ├── GeographyStep.vue # Step 1 - citizenship & destination
│ ├── PersonalDetailsForm.vue # Step 2 - applicant details
│ ├── ReviewSubmit.vue # Step 3 - review summary
│ ├── StepProgress.vue # Wizard step indicator
│ └── ApplicationsList.vue # Submitted applications table
├── composables/ # useVisaWizard, useCountries, scrollbar / click-outside
├── utils/ # visaValidator, applicationStorage
└── assets/ # CSS, icons, images
server/
└── api/countries.get.ts # REST Countries proxy + TTL cache
test/ # Vitest unit tests
| Variable | Required | Description |
|---|---|---|
NUXT_REST_COUNTRIES_API_KEY |
Yes | Bearer token for the REST Countries v5 API |