Skip to content

Frontend

Technology Stack

  • SolidJS 1.9 - UI framework (fine-grained reactivity, no virtual DOM)
  • TypeScript 5.9 - type safety (erasableSyntaxOnly enabled)
  • Vite 7 - build tool
  • @solidjs/router - client-side routing
  • TanStack Solid Query - server state management
  • openapi-fetch - type-safe API client (generated from OpenAPI specs)
  • oidc-client-ts - Keycloak OIDC authentication
  • @solid-primitives/i18n - internationalization (EN/PL)
  • solid-icons - icon library
  • CSS Modules - component styling
  • @jankrajewskiit/ui - shared UI component library (published to GitHub Packages)

Build & Run

cd recron-web && npm install       # install dependencies
cd recron-web && npm run dev       # dev server (port 5173)
cd recron-web && npm run build     # tsc -b && vite build
cd recron-web && npx tsc --noEmit  # typecheck only
cd recron-web && npm run lint      # eslint .
cd recron-web && npm run lint:fix  # eslint . --fix

OpenAPI Type Generation

Three microservices expose separate OpenAPI specs through the YARP gateway at http://localhost:5000. Generated types provide:

  • Typed URL paths — compile-time validation of endpoint URLs
  • Typed request bodies — field names and types checked at compile time
  • IDE autocomplete — available endpoints suggested when typing

Commands

# Generate types for all services (run from recron-web/)
npx openapi-typescript http://localhost:5000/organizer/openapi/v1.json -o src/api/schema/organizer.d.ts
npx openapi-typescript http://localhost:5000/questions/openapi/v1.json -o src/api/schema/questions.d.ts
npx openapi-typescript http://localhost:5000/users/openapi/v1.json     -o src/api/schema/users.d.ts

When to regenerate

Re-run these commands whenever backend endpoints, request bodies, or response schemas change. The backend must be running (via dotnet run in Recron.AppHost) for the specs to be accessible.

Generated files

File Source Description
src/api/schema/organizer.d.ts Organizer.Api Offers, interviews, dictionaries, AI parsing, stats
src/api/schema/questions.d.ts Questions.Api Categories, questions, AI, quiz
src/api/schema/users.d.ts Users.Api Token details, user profile

Missing response schemas

Most backend endpoints currently return 200 OK without a declared response body schema. The generated types have content?: never for these responses, so we use manually-ported model types in src/models/ and cast responses: data as unknown as Offer[]. If the backend adds proper Produces<T>() declarations, regenerating the types will eliminate the need for these casts.

Project Structure

recron-web/
├── src/
│   ├── api/
│   │   ├── client.ts              # Per-service openapi-fetch clients with auth middleware
│   │   ├── query.ts               # TanStack QueryClient config
│   │   └── schema/                # Auto-generated OpenAPI types (DO NOT edit manually)
│   │       ├── organizer.d.ts
│   │       ├── questions.d.ts
│   │       └── users.d.ts
│   ├── components/
│   │   ├── common/                # Recron-specific UI components
│   │   │   └── RequireAuth/               # Role-based auth guard + AuthRedirect (uses Recron auth)
│   │   │
│   │   │   # All UI primitives (Button, Dialog, Input, LocaleSwitcher, ThemeToggle, etc.)
│   │   │   # and layout shells (Navbar, Footer, MainLayout) come from @jankrajewskiit/ui.
│   │   │   # See: docs/technical/shared-packages.md
│   │   ├── layout/                # Thin wrappers wiring @jankrajewskiit/ui layout to Recron stores
│   │   │   ├── MainLayout/                # Wraps VelvetUi MainLayout with auth-conditional navbar + i18n footer
│   │   │   │   └── MainLayout.tsx
│   │   │   └── Navbar/                    # Wraps VelvetUi Navbar with i18n labels + app-specific slots
│   │   │       ├── Navbar.tsx             # Thin wrapper: resolves i18n, passes to VelvetUi Navbar
│   │   │       ├── NavItem.ts             # NavItem config with DictKey labels + resolveNavItems()
│   │   │       ├── NavLogo.tsx            # Recron logo icon (passed as logo slot)
│   │   │       └── AuthButton.tsx         # Login/logout toggle (passed as actions slot)
│   │   ├── organizer/             # Job organizer feature
│   │   │   ├── Organizer.tsx              # Feature container
│   │   │   ├── Dashboard/                 # Statistics dashboard
│   │   │   │   └── Dashboard.tsx          # Stats overview (totals, charts)
│   │   │   ├── OfferTable.tsx             # Offer table
│   │   │   ├── OfferTableRow.tsx          # Table row
│   │   │   ├── OfferDrawer.tsx            # Offer detail drawer
│   │   │   ├── DrawerContent.tsx          # Drawer tabs (details, interviews, AI match)
│   │   │   ├── StatusTabs.tsx             # Status filter tabs
│   │   │   ├── AddOfferWizard.tsx         # Add offer (manual / URL / text AI parse)
│   │   │   ├── DefineOfferDialog.tsx      # Edit offer dialog
│   │   │   ├── DeleteOffer.tsx            # Delete offer confirmation
│   │   │   ├── ChangeStateDialog.tsx      # Status change dialog
│   │   │   ├── OfferFormFields.tsx        # Shared form fields
│   │   │   ├── SalaryFields.tsx           # Salary form fields
│   │   │   ├── ReasonsAutocomplete.tsx    # Reasons selector
│   │   │   ├── RedirectToOffer.tsx        # External link button
│   │   │   ├── MatchCvPanel.tsx           # AI CV-to-offer matching panel
│   │   │   ├── InterviewTimeline.tsx      # Interview timeline
│   │   │   ├── InterviewTimelineItem.tsx  # Timeline entry
│   │   │   ├── InterviewCard/             # Interview card with actions
│   │   │   │   └── InterviewCard.tsx      # Card with markdown notes
│   │   │   ├── InterviewStatusMenu/       # Interview status change
│   │   │   │   └── InterviewStatusMenu.tsx
│   │   │   ├── AddInterview.tsx           # Add interview dialog
│   │   │   ├── AddToCalendar.tsx          # Google Calendar link
│   │   │   └── DragDropOffers.tsx         # Drag & drop reorder
│   │   ├── portfolio/             # CV portfolio builder
│   │   │   ├── Portfolio.tsx              # Main orchestrator (editor/preview tabs)
│   │   │   ├── PersonalInfoForm/          # Personal info grid form
│   │   │   ├── SummaryForm/               # Professional summary editor
│   │   │   ├── ExperienceForm/            # Work experience CRUD list
│   │   │   ├── ProjectsForm/              # Projects CRUD list
│   │   │   ├── SkillsForm/                # Skills inline CRUD
│   │   │   ├── EducationForm/             # Education CRUD list
│   │   │   ├── CertificationsForm/        # Certifications CRUD list
│   │   │   ├── LanguagesForm/             # Languages inline CRUD
│   │   │   ├── SectionCard/               # Collapsible accordion section
│   │   │   ├── VariantSelector/           # Portfolio variant management
│   │   │   ├── ImportPortfolioDialog/     # Import from LinkedIn/PDF
│   │   │   ├── CvPreview/                 # Minimalist single-column CV template
│   │   │   ├── TwoColumnCvPreview/        # Two-column CV template
│   │   │   └── BurgundyCvPreview/         # Burgundy-themed CV template
│   │   ├── questions/             # Questions feature
│   │   │   ├── Questions.tsx              # Feature container
│   │   │   ├── QuestionItem.tsx           # Question card
│   │   │   ├── QuestionAnswer.tsx         # Answer display + Ask AI
│   │   │   ├── QuestionFilters.tsx        # Filter by status (all/learning/pending)
│   │   │   ├── ProposeQuestionButton.tsx  # Propose new question
│   │   │   ├── Categories.tsx             # Category tree
│   │   │   ├── MainCategories.tsx         # Top-level categories
│   │   │   ├── MainCategory.tsx           # Category card
│   │   │   ├── ChildCategories.tsx        # Subcategories
│   │   │   ├── Quiz/                      # Quiz / flashcard system
│   │   │   │   ├── Quiz.tsx               # Quiz orchestrator (setup→session→summary)
│   │   │   │   ├── QuizSetup/             # Category + count selector
│   │   │   │   ├── QuizSession/           # Progress bar + flashcard rating
│   │   │   │   ├── QuizSummary/           # Results with percentage
│   │   │   │   └── Flashcard/             # Flip card (question/answer)
│   │   │   └── actions/                   # Question action dialogs
│   │   │       ├── QuestionActions.tsx
│   │   │       ├── EditQuestionDialog.tsx
│   │   │       └── EditAnswerDialog.tsx
│   │   └── settings/              # Settings feature
│   │       └── Profile/                   # User profile
│   │           ├── Profile.tsx            # Profile page wrapper
│   │           └── ProfileForm.tsx        # Editable profile form
│   ├── hooks/
│   │   └── useAddOfferWizard.ts   # Add offer wizard state (Recron-specific)
│   │   # useClickOutside and useEscapeKey come from @jankrajewskiit/ui
│   ├── i18n/
│   │   └── locales/
│   │       ├── en.ts              # English translations (source of truth)
│   │       └── pl.ts              # Polish translations
│   ├── models/                    # TypeScript interfaces & const enums
│   ├── pages/                     # Route page components (lazy-loaded)
│   ├── store/                     # State management (queries, mutations, signals)
│   ├── theme/                     # Theming
│   │   └── globals.css            # CSS custom properties (design tokens)
│   ├── App.tsx                    # Router + provider stack
│   └── index.tsx                  # Entry point
├── .env                           # Local environment variables
└── .env.example                   # Template

Routes

All page components are lazy-loaded via lazy(). Auth is enforced inside page components using <RequireAuth>, not at the router level.

Route Page Component Auth Required Roles Description
/ Home No Homepage
/privacy Privacy No Privacy policy
/questions Questions Yes user, moderator, admin Interview questions & learning
/quiz Quiz Yes user, moderator, admin Quiz / flashcard sessions
/organizer Organizer Yes user, moderator, admin Job offer tracking & dashboard
/portfolio Portfolio Yes user, moderator, admin CV portfolio builder
/settings Settings Yes user, moderator, admin User profile & preferences
*404 NotFound No 404 page

Component Patterns

Props Convention

All components use the Props<T> utility type from @jankrajewskiit/ui which adds class?: string:

import { type Props } from "@jankrajewskiit/ui";

interface MyComponentProps {
  title: string;
}

const MyComponent = (props: Props<MyComponentProps>) => {
  return (
    <div class={`${styles.wrapper} ${props.class ?? ""}`}>
      {props.title}
    </div>
  );
};

export default MyComponent;

Rules

  • Always use arrow functions for components — never function declarations
  • Always use Props<T> — never manually declare class?: string
  • Always forward class on the top-level element
  • NEVER destructure props — access as props.name to preserve SolidJS reactivity
  • One component per file, each in its own directory with co-located CSS Module

State Management

Store Files

File Purpose
auth.tsx Keycloak OIDC provider (oidc-client-ts + SolidJS context)
i18n.tsx i18n provider (EN/PL, lazy-loaded dictionaries)
organizer.ts Offers signals, queries, mutations
questions.ts Questions/categories signals, queries, mutations
interviews.ts Interview queries and mutations
dictionary.ts Dictionary queries (positions, cities, reasons)
users.ts User profile query and save mutation
portfolio.ts Portfolio store with localStorage persistence
calendar.ts Google Calendar OAuth status and connect/disconnect
tags.ts User-defined offer tags queries and mutations

Query Hooks

// organizer.ts
useOfferCountsQuery()        // Offer counts by status
useOffersStatsQuery()        // Dashboard statistics
useOffersQuery()             // All user offers
useCompareOffersQuery()      // Compare selected offers side by side

// questions.ts
useCategoriesQuery()         // Category tree
useQuestionsQuery()          // Questions for selected category
useQuizQuery()               // Quiz questions for a category + count

// interviews.ts
useInterviewsQuery()         // Interviews for an offer

// dictionary.ts
usePositionsQuery()          // Position dictionary
useCitiesQuery()             // City dictionary
useReasonsQuery()            // Rejection reason dictionary

// users.ts
useUserProfileQuery()        // Current user profile

// portfolio.ts
usePortfolioVariantsQuery()  // All portfolio variants for current user

// calendar.ts
useGoogleCalendarStatusQuery() // Check Google Calendar OAuth status

Mutation Hooks

// organizer.ts
useAddOfferMutation()           // Create offer
useUpdateOfferMutation()        // Edit offer
useUpdateOfferStateMutation()   // Change offer status
useDeleteOfferMutation()        // Delete offer
useDuplicateOfferMutation()     // Duplicate offer
useParseOfferAiMutation()       // AI parse offer from URL/text
useMatchCvMutation()            // AI CV-to-offer matching
useScoreOfferMutation()         // AI score offer compatibility
useGenerateCoverLetterMutation()// AI generate cover letter

// questions.ts
useAskAiMutation()           // Generate AI answer
usePromoteQuestionMutation() // Upvote question
useAddToLearningMutation()   // Add to learning list
useRemoveFromLearningMutation() // Remove from learning list
useUpdateQuestionMutation()  // Edit question
useUpdateAnswerMutation()    // Edit answer
useReorderQuestionMutation() // Reorder question
useProposeQuestionMutation() // Propose new question
useApproveQuestionMutation() // Approve pending question (moderator)
useRejectQuestionMutation()  // Reject pending question (moderator)
useDeleteQuestionMutation()  // Delete question (moderator)

// interviews.ts
useAddInterviewMutation()    // Schedule interview
useUpdateInterviewMutation() // Update interview
useDeleteInterviewMutation() // Delete interview

// users.ts
useSaveProfileMutation()     // Save user profile

// portfolio.ts
useParseLinkedInMutation()   // Import from LinkedIn URL
useParseCvPdfMutation()      // Import from CV PDF upload
useCheckAtsMutation()        // AI ATS compatibility check

// calendar.ts
useConnectGoogleCalendarMutation()    // Start Google Calendar OAuth flow
useDisconnectGoogleCalendarMutation() // Revoke Google Calendar tokens

Portfolio Store (Local-First Pattern)

The portfolio store uses a local-first approach different from the other stores:

  • Portfolio data is managed via createStore() with localStorage persistence
  • Changes are debounced (500ms) and synced to the API automatically
  • Supports multiple named variants (create, load, delete, rename)
  • Provides granular update functions: updatePersonalInfo(), updateSummary(), addExperience(), updateExperience(), removeExperience(), and similar for projects, skills, education, certifications, languages
  • API communication uses authFetch() (raw fetch with auth) instead of the openapi-fetch client

Mutation Pattern

All mutations use onSettled (never onSuccess) for query invalidation:

export const useDeleteOfferMutation = () => {
  const queryClient = useQueryClient();

  return createMutation(() => ({
    mutationKey: ["offers", "delete"],
    mutationFn: async (offerId: string) => {
      await organizerApi.DELETE("/offers/{offerId}", {
        params: { path: { offerId } },
      });
    },
    onSettled: () => {
      void queryClient.invalidateQueries({ queryKey: ["offers"] });
      void queryClient.invalidateQueries({ queryKey: ["offerCounts"] });
    },
  }));
};

API Client Architecture

openapi-fetch Clients (Organizer, Questions, Users)

Three separate openapi-fetch clients, one per microservice, each with the gateway prefix baked into baseUrl:

// src/api/client.ts
export const organizerApi = createClient<OrganizerPaths>({
  baseUrl: `${baseUrl}/organizer`,
});
organizerApi.use(authMiddleware); // injects Bearer token

The auth middleware calls getAccessToken() (from store/auth.tsx) on every request, handling silent token renewal when tokens expire.

authFetch (Portfolio)

Portfolio.Api uses a different pattern — a raw fetch wrapper with auth header injection:

// Used in portfolio store for all Portfolio.Api calls
const authFetch = async (url: string, options?: RequestInit) => {
  const token = await getAccessToken();
  return fetch(url, {
    ...options,
    headers: { ...options?.headers, Authorization: `Bearer ${token}` },
  });
};

Feature Components

Dashboard

src/components/organizer/Dashboard/Dashboard.tsx

Displays aggregated statistics for the user's job search:

  • Summary cards — total offers, total interviews, conversion rate (%), average salary range
  • Status distribution — chips showing counts per offer status
  • Source breakdown — bar chart of offer sources (LinkedIn, NoFluffJobs, etc.)
  • Work mode distribution — bar chart of remote/hybrid/onsite
  • Interview stages — bar chart of interview counts by stage
  • Weekly activity — vertical bar chart of recent offer/interview activity

Quiz / Flashcards

src/components/questions/Quiz/

Four-phase quiz flow:

  1. Setup (QuizSetup) — select category from tree + number of questions
  2. Loading — fetch quiz questions via useQuizQuery()
  3. Session (QuizSession) — progress bar, flashcard display, know/don't-know rating per card
  4. Summary (QuizSummary) — shows percentage, known/unknown/total counts, restart option

The Flashcard component renders a flip card with the question on the front and the markdown-rendered answer on the back.

CV Portfolio Builder

src/components/portfolio/Portfolio.tsx

Full CV builder with:

  • Editor tab — form sections (personal info, summary, experience, projects, skills, education, certifications, languages) in collapsible SectionCard accordions
  • Preview tab — live preview with 3 CV templates:
    • CvPreview — minimalist single-column
    • TwoColumnCvPreview — two-column (sidebar: contact/skills/languages/certs; main: summary/experience/projects/education)
    • BurgundyCvPreview — burgundy-themed with dot-based skill/language levels
  • Variant management (VariantSelector) — create, rename, delete, switch between multiple CV versions
  • Import (ImportPortfolioDialog) — import from LinkedIn URL or CV PDF upload (drag-and-drop)
  • Export — download as PDF, HTML, or JSON

AI CV-to-Offer Matching

src/components/organizer/MatchCvPanel.tsx

Located inside the offer drawer (DrawerContent.tsx), allows matching a user's CV against a job offer:

  • Variant selector — choose which portfolio variant to match
  • Match action — sends portfolio data + offer to AI via useMatchCvMutation()
  • Result display — visual ScoreRing (SVG percentage), summary text, strengths list, gaps list, recommendations list

User Profile

src/components/settings/Profile/

  • Profile.tsx — wrapper that shows auth state, renders ProfileForm + LocaleSwitcher under preferences
  • ProfileForm.tsx — editable form for: display name, bio, preferred role, experience years, location, skills (comma-separated), LinkedIn URL, GitHub URL, website URL

CSS Modules

/* Component.module.css */
.container {
  padding: var(--spacing-md);
  background: var(--color-surface);
  border-radius: var(--radius-md);
}
import styles from "./Component.module.css";

const Component = (props: Props<ComponentProps>) => {
  return (
    <div class={`${styles.container} ${props.class ?? ""}`}>
      <h2 class={styles.title}>{props.title}</h2>
    </div>
  );
};

Rules:

  • One .module.css file per component, co-located in the same directory
  • Use camelCase class names: .menuItem, .addButton
  • Use CSS custom properties for theming: var(--color-primary), var(--spacing-md)
  • Never import CSS modules from another component's directory

Enum Pattern

TypeScript 5.9 with erasableSyntaxOnly does not allow enum. Use const objects:

const OfferStatus = {
  New: "New",
  Considered: "Considered",
  Sent: "Sent",
  Rejected: "Rejected",
} as const;

type OfferStatus = (typeof OfferStatus)[keyof typeof OfferStatus];
export default OfferStatus;

Authentication

Keycloak OIDC via oidc-client-ts wrapped in a SolidJS context:

const { isAuthenticated, signIn, signOut, roles, hasRole } = useAuth();

RequireAuth Guard

Auth is enforced inside page components (not at the router level). When unauthenticated, AuthRedirect automatically triggers sign-in. When the user lacks required roles, AccessDenied is shown.

<RequireAuth roles={["user", "moderator", "admin"]}>
  <ProtectedContent />
</RequireAuth>

Environment variables:

Variable Example
VITE_AUTH_ISSUER https://auth.bluebraces.online/realms/recron
VITE_AUTH_CLIENT_ID frontend
VITE_AUTH_URL http://localhost:5173
VITE_API_BASE_URL http://localhost:5000

Internationalization

Translation Structure

// en.ts (source of truth for types via RawDictionary)
export default {
  common: { cancel: "Cancel", save: "Save" },
  organizer: {
    offerStatus: { new: "New", considered: "Considered" },
    interviewStage: { screening: "Screening", technical: "Technical" },
  },
  portfolio: { title: "CV Portfolio", ... },
  quiz: { title: "Quiz", ... },
  settings: { profile: { title: "Profile", ... } },
} as const;

Usage: t('key') for reactive translations, ts('key') when the return value must be string (e.g., template literals, aria-label).

Enum Translation Pattern

import { type DictKey } from "~/store/i18n";

export const OfferStatusTranslationKeys: Record<OfferStatus, DictKey> = {
  [OfferStatus.New]: "organizer.offerStatus.new",
  [OfferStatus.Considered]: "organizer.offerStatus.considered",
  [OfferStatus.Sent]: "organizer.offerStatus.sent",
  [OfferStatus.Rejected]: "organizer.offerStatus.rejected",
};

// Usage
const { t } = useI18n();
const label = t(OfferStatusTranslationKeys[offer.status]);

Domain Models

Offer

interface Offer {
  id: string;
  name: string;
  link: string;
  company: string;
  city: string;
  status: OfferStatus;
  reasons: string[];
  description: string | null;
  source: string | null;
  workMode: string | null;
  intermediary: string | null;
  salaryMin: number | null;
  salaryMax: number | null;
  salaryCurrency: string | null;
  salaryType: string | null;
  order: number;
  isAiGenerated: boolean;
  rawContent: string | null;
  sourceUrl: string | null;
}

OffersStats

interface OffersStats {
  totalOffers: number;
  totalInterviews: number;
  statusCounts: StatusCount[];
  sourceCounts: SourceCount[];
  workModeCounts: WorkModeCount[];
  weeklyActivity: WeeklyActivity[];
  interviewStageCounts: InterviewStageCount[];
}

Interview

interface Interview {
  id: string;
  offerId: string;
  stage: InterviewStage;
  status: InterviewStatus;
  scheduledAt: string | null;
  notes: string | null;
  location: string | null;
  duration: number | null;
}

Question

interface Question {
  id: string;
  name: string;
  description: string | null;
  promoteCount: number;
  hasUserVoted: boolean;
  isInLearning: boolean;
  isPending: boolean;
}

QuizQuestion

interface QuizQuestion {
  id: string;
  name: string;
  description: string;
  categoryName: string;
}

Category (hierarchical)

interface Category {
  id: string;
  name: string;
  description: string | null;
  iconName: string | null;
  childCategories: Category[];
}

PortfolioData

interface PortfolioData {
  personalInfo: PersonalInfo;
  summary: string;
  experience: WorkExperience[];
  projects: Project[];
  skills: Skill[];
  education: Education[];
  certifications: Certification[];
  languages: Language[];
}

interface PersonalInfo {
  firstName: string;
  lastName: string;
  email: string;
  phone: string;
  location: string;
  linkedIn: string;
  website: string;
}

interface WorkExperience {
  id: string;
  company: string;
  position: string;
  startDate: string;
  endDate: string;
  current: boolean;
  description: string;
}

interface Skill {
  id: string;
  name: string;
  level: SkillLevel; // "Beginner" | "Intermediate" | "Advanced" | "Expert"
}

interface Language {
  id: string;
  name: string;
  level: LanguageLevel; // "A1" | "A2" | "B1" | "B2" | "C1" | "C2" | "Native"
}

UserProfile

interface UserProfile {
  displayName: string;
  bio: string | null;
  preferredRole: string | null;
  experienceYears: number | null;
  location: string | null;
  skills: string | null;
  linkedInUrl: string | null;
  gitHubUrl: string | null;
  websiteUrl: string | null;
}

MatchCvResult

interface MatchCvResult {
  matchScore: number;
  summary: string;
  strengths: string[];
  gaps: string[];
  recommendations: string[];
}

ParseOfferRequest / ParseOfferResponse

interface ParseOfferRequest {
  mode: ParseMode;  // "Url" | "Text" | "Manual"
  content: string;
}

interface ParseOfferResponse {
  name: string;
  company: string;
  city: string;
  link: string;
  description: string | null;
  source: string | null;
  workMode: string | null;
  intermediary: string | null;
  salaryMin: number | null;
  salaryMax: number | null;
  salaryCurrency: string | null;
  salaryType: string | null;
}

Const Enum Types

Type Values
OfferStatus New, Considered, Sent, Rejected
OfferSource LinkedIn, NoFluffJobs, JustJoinIT, Pracuj, Email, Direct, Other
InterviewStage Screening, Technical, HR, Offer, Onboarding
InterviewStatus Scheduled, Completed, Failed, Cancelled
ParseMode Url, Text, Manual
WorkMode Remote, Hybrid, Onsite
SalaryType Monthly, Hourly, Annual
SalaryCurrency PLN, EUR, USD
SkillLevel Beginner, Intermediate, Advanced, Expert
LanguageLevel A1, A2, B1, B2, C1, C2, Native