diff --git a/devNotes.md b/devNotes.md index ef9d6b5..2eb6377 100644 --- a/devNotes.md +++ b/devNotes.md @@ -1 +1,97 @@ # Developer Notes + +**Login Credentials:** +For demonstration purposes, authentication uses a static email and password: +- **Email:** `user@example.com` +- **Password:** `password123` + +## Tech Stack +- **Framework:** Vue 3 + Vite +- **Styling:** SCSS with variables in `_variables.scss` +- **Routing:** Vue Router +- **State Management:** Local component state +- **API Layer:** `apiService.ts` for data fetching abstraction +- **Icons:** Installed **FontAwesome** package for icons such as close, save, logout, create new record, actions, and pagination buttons. + +## Flow Overview + +### 1. SignIn Page +- Simulated authentication using `localStorage` flag (`isAuthenticated`). +- **Validation:** Ensures both email and password fields are non-empty. +- If either email or password is invalid, a **pop-up modal** appears saying *"Email or password is incorrect"*. +- On successful login: + - A `loggedIn` key with a value of `true` is stored in **localStorage**. + - User is redirected to **Dashboard**. +- **Auto Logout:** After 15 minutes of inactivity, the user is automatically logged out, redirected back to Sign In, and must log in again. + +### 2. Dashboard +- Fetches initial client data from static `clients.json` via `apiService.ts`. +- Displays data using the **`TableList`** component. +- **Table Columns:** + - **Actions** (dropdown with View, Edit, Delete) + - **Name** + - **Company** + - **Subscription Cost** (with currency displayed beside the value) + - **Age** +- **Pagination:** + - Implemented with `<`, `<<`, numbered pages (`1`, `2`, ...), `>>`, and `>` buttons. + - **Per page limit:** 10 records. + - If there are more than 10 records, remaining records are shown on additional pages. + - Blue pagination buttons indicate they are clickable. + - **Record Ordering:** Newly created records are inserted **at the top** of the list (most recent first), so they are visible immediately on the first page. + - This ordering is handled by sorting records in descending order based on `id`. +- **Buttons:** + - **Logout:** Logs the user out and redirects to Sign In. + - **+ Create new record:** Opens `ModalForm` for adding new records. +- **Actions Column Details:** + - Implemented as a dropdown with **View**, **Edit**, and **Delete** options: + - **View:** Reuses the same modal structure to display: + - Profile picture + - Name + - Company + - Subscription cost + - Currency + - Age (hidden if not provided) + - Gender (hidden if not provided) + - Date registered (formatted as `"May 12, 2004"`) + - Modal can be closed. + - **Edit:** Reuses the create record modal but runs edit logic to update the existing record instead. + - **Delete:** Shows a confirmation modal asking the admin to confirm deletion and explicitly stating the client’s name to prevent accidental deletions. + +### 3. ModalForm +- Reusable modal for creation of new client records. +- Emits submitted data to parent (`Dashboard.vue`) for table update. +- **Validation Rules:** + - Required: **name, company, subscription cost, currency**. + - An asterisk (`*`) is displayed beside required fields. + - **Subscription cost** and **age** must be **≥ 0**. + - **Currency:** Uses a dropdown list populated via the `currency-codes` package. User can type to auto-search and set the currency for quick navigation and reduced errors. + - **Optional fields:** Age and Gender (Gender uses an enum — `0` for male, `1` for female). + - **Save button** is disabled unless all required fields are filled. +- **User Actions in Modal:** + - Save record (if valid). + - Cancel creation. + - Close modal. + +## Validation Rules Summary +- **SignIn.vue:** + - Required: email, password. + - Invalid email/password → pop-up modal error message. +- **ModalForm.vue:** + - Required: name, company, subscription cost, currency. + - Optional: age, gender. + - No negative values for subscription cost or age. + - Currency selection via searchable dropdown (currency-codes package). + - Save disabled unless required fields are complete. + +## File Responsibilities +- **`main.ts`**: Application entry, mounts Vue instance, registers router & styles. +- **`router.ts`**: Defines routes (`/signin`, `/dashboard`) with guards. +- **`apiService.ts`**: Encapsulates API calls to fetch data. +- **`record.ts`**: TypeScript interfaces/types for records. +- **`SignIn.vue`**: Login form & authentication logic. +- **`Dashboard.vue`**: Main authenticated page, data fetching & management. +- **`TableList.vue`**: Generic table rendering component with dropdown actions, delete confirmation modal, and pagination with **latest-first sorting**. +- **`ModalForm.vue`**: Modal popup for record creation with validation. +- **`main.scss`**: Global styles. +- **`_variables.scss`**: Style variables for theme consistency. diff --git a/index.html b/index.html new file mode 100644 index 0000000..9c463d9 --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + CMS + + +
+ + + diff --git a/package.json b/package.json index bf9fa05..df4873c 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,29 @@ { - "name": "virtusize-assignment", - "version": "1.0.0", - "description": "A frontend coding challenge for Virtusize Frontend team", - "main": "index.js", + "name": "frontend-assignment", + "private": true, + "version": "0.0.0", + "type": "module", "scripts": { - "start": "", - "api": "json-server --watch clients.json --port 4090", - "test": "" + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "api": "json-server --watch clients.json --port 3000" }, - "author": "virtusize", - "license": "UNLICENSED", "dependencies": { - "json-server": "^1.0.0-beta.2" + "@fortawesome/fontawesome-svg-core": "^7.0.0", + "@fortawesome/free-solid-svg-icons": "^7.0.0", + "@fortawesome/vue-fontawesome": "^3.1.1", + "currency-codes": "^2.2.0", + "vue": "^3.5.18", + "vue-router": "^4.5.1" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.1", + "@vue/tsconfig": "^0.7.0", + "json-server": "^1.0.0-beta.3", + "sass": "^1.90.0", + "typescript": "~5.8.3", + "vite": "^7.1.0", + "vue-tsc": "^3.0.5" } } diff --git a/public/icon.svg b/public/icon.svg new file mode 100644 index 0000000..4dba767 --- /dev/null +++ b/public/icon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/App.vue b/src/App.vue new file mode 100644 index 0000000..f857a6e --- /dev/null +++ b/src/App.vue @@ -0,0 +1,19 @@ + + + diff --git a/src/apiService.ts b/src/apiService.ts new file mode 100644 index 0000000..91a26e9 --- /dev/null +++ b/src/apiService.ts @@ -0,0 +1,52 @@ +import type { RecordItem } from "./record"; + +const API_URL = 'http://localhost:3000'; + +async function request(endpoint: string, options: RequestInit = {}): Promise { + const res = await fetch(`${API_URL}${endpoint}`, { + headers: { 'Content-Type': 'application/json' }, + ...options + }); + + if (!res.ok) { + throw new Error(`HTTP error! Status: ${res.status}`); + } + + return res.json(); +} + +export async function getRecords(): Promise { + return request('/clients'); +} + +export async function getRecordedById(id: number): Promise { + return request(`/clients/${id}`); +} + +export async function createRecord(record: any): Promise { + return request(`/clients`, { + method: 'POST', + body: JSON.stringify(record) + }); +} + +export async function updateRecord(id: number, record: any): Promise { + return request(`/clients/${id}`, { + method: 'PUT', + body: JSON.stringify(record) + }); +} + +export async function deleteRecord(id: number): Promise { + return request(`/clients/${id}`, { + method: 'DELETE' + }); +} + +export default { + getRecords, + getRecordedById, + createRecord, + updateRecord, + deleteRecord +}; diff --git a/src/components/ModalForm.vue b/src/components/ModalForm.vue new file mode 100644 index 0000000..13c0f32 --- /dev/null +++ b/src/components/ModalForm.vue @@ -0,0 +1,516 @@ + + + + + diff --git a/src/components/TableList.vue b/src/components/TableList.vue new file mode 100644 index 0000000..da42451 --- /dev/null +++ b/src/components/TableList.vue @@ -0,0 +1,270 @@ + + + + + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..3014b28 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import App from './App.vue' +import router from './router.ts' +import './styles/main.scss' +import { library } from '@fortawesome/fontawesome-svg-core' +import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' +import { faCog, faPlus, faChevronDown, faAnglesLeft, faAngleLeft, faAngleRight, faAnglesRight, faTimes, faSignOutAlt, faSave } from '@fortawesome/free-solid-svg-icons' + +library.add(faCog, faPlus, faChevronDown, faAnglesLeft, faAngleLeft, faAngleRight, faAnglesRight, faTimes, faSignOutAlt, faSave) + +const app = createApp(App) +app.component('font-awesome-icon', FontAwesomeIcon) +app.use(router).mount('#app') diff --git a/src/record.ts b/src/record.ts new file mode 100644 index 0000000..f1c0ddb --- /dev/null +++ b/src/record.ts @@ -0,0 +1,16 @@ +export interface RecordItem { + id: number; + gender: Gender | null | ''; + name: string; + company: string; + age: number | null; + picture?: string; + registered?: string; + currency: string; + subscriptionCost: number; +} + +export enum Gender { + Male = 0, + Female = 1 +} diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..627c5f5 --- /dev/null +++ b/src/router.ts @@ -0,0 +1,28 @@ +import { createWebHistory, createRouter, type RouteRecordRaw } from "vue-router"; +import Dashboard from "./views/Dashboard.vue"; +import SignIn from "./views/SignIn.vue"; + +const routes: RouteRecordRaw[] = [ + { path: '/', redirect: 'signin' }, + { path: '/signin', name: 'SignIn', component: SignIn }, + { path: '/dashboard', name: 'Dashboard', component: Dashboard, meta: { requiresAuth: true } } +]; + +const router = createRouter({ + history: createWebHistory(), + routes +}); + +router.beforeEach((to, from, next) => { + const loggedIn = localStorage.getItem('loggedIn') === 'true'; + + if (to.meta.requiresAuth && !loggedIn) { + next('/signin'); + } else if (to.path === '/signin' && loggedIn) { + next('/dashboard'); + } else { + next(); + } +}); + +export default router; diff --git a/src/styles/_variables.scss b/src/styles/_variables.scss new file mode 100644 index 0000000..b7c484d --- /dev/null +++ b/src/styles/_variables.scss @@ -0,0 +1,61 @@ +$primary: #007bff; // blue +$primary-dark: #0056b3; // darker blue for hover +$primary-darker: #004494; // even darker blue for focus outline + +$secondary: #e0e0e0; // gray light +$secondary-hover: #cacaca; // gray hover + +$danger: #dc3545; // red +$danger-hover: #b1271b; // darker red for hover + +$background-color: #f5f7fa; // page background +$background-white: #fff; + +$text-color: #333; +$text-color-dark: #555; +$text-color-light: #777; + +$border-color: #ccc; +$border-light: #ddd; +$border-lighter: #f1f1f1; + +$modal-overlay-bg: rgba(0, 0, 0, 0.4); + +$input-focus-color: $primary; // blue +$input-focus-shadow: rgba(0, 123, 255, 0.4); // semi-transparent blue + +$dropdown-hover-bg: #f0f0f0; + +$nav-border: #eee; + +:root { + --primary: #{$primary}; + --primary-dark: #{$primary-dark}; + --primary-darker: #{$primary-darker}; + + --secondary: #{$secondary}; + --secondary-hover: #{$secondary-hover}; + + --danger: #{$danger}; + --danger-hover: #{$danger-hover}; + + --background-color: #{$background-color}; + --background-white: #{$background-white}; + + --text-color: #{$text-color}; + --text-color-dark: #{$text-color-dark}; + --text-color-light: #{$text-color-light}; + + --border-color: #{$border-color}; + --border-light: #{$border-light}; + --border-lighter: #{$border-lighter}; + + --modal-overlay-bg: #{$modal-overlay-bg}; + + --input-focus-color: #{$input-focus-color}; + --input-focus-shadow: #{$input-focus-shadow}; + + --dropdown-hover-bg: #{$dropdown-hover-bg}; + + --nav-border: #{$nav-border}; +} diff --git a/src/styles/main.scss b/src/styles/main.scss new file mode 100644 index 0000000..1b9617a --- /dev/null +++ b/src/styles/main.scss @@ -0,0 +1,396 @@ +@use './variables' as vars; +@use "sass:color"; + +/* Reset and base body styling */ +body { + font-family: 'Inter', sans-serif; + background-color: vars.$background-color; + margin: 0; + padding: 5rem; + color: vars.$text-color; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +/* Container helper */ +.container { + max-width: 900px; + margin: auto; + padding: 1rem; +} + +/* Table styles */ +.table { + width: 100%; + border-collapse: collapse; + background: vars.$background-white; + border-radius: 8px; + margin-bottom: 2rem; + + th { + background: #f8f9fa; + padding: 0.6rem; + text-align: left; + font-weight: 600; + border-bottom: 1px solid vars.$border-light; + } + + td { + padding: 0.6rem; + border-bottom: 1px solid vars.$border-lighter; + } + + tr:hover { + background: #f6f8fa; + } +} + +/* Base button styles */ +.button { + background-color: vars.$primary; + color: vars.$background-white; + padding: 0.4rem 1rem; + font-size: 0.95rem; + border: none; + border-radius: 6px; + cursor: pointer; + min-width: 80px; + text-align: center; + user-select: none; + transition: background-color 0.3s ease, box-shadow 0.2s ease; + + &:hover:not(:disabled) { + background-color: vars.$primary-dark; + box-shadow: 0 0 8px #{color.adjust(vars.$primary, $alpha: 0.4)}; + } + + &:focus-visible { + outline: 2px solid vars.$primary-darker; + outline-offset: 2px; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + pointer-events: none; + } +} + +/* Button modifiers */ +.btn-primary { + @extend .button; + background-color: vars.$primary; + + &:hover:not(:disabled) { + background-color: vars.$primary-dark; + } +} + +.btn-secondary { + @extend .button; + background-color: vars.$secondary; + color: vars.$text-color; + + &:hover:not(:disabled) { + background-color: vars.$secondary-hover; + } +} + +.btn-danger { + @extend .button; + background-color: vars.$danger; + color: vars.$background-white; + + &:hover:not(:disabled) { + background-color: color.adjust(vars.$danger, $lightness: -10%); + } +} + +/* Navigation bar */ +nav { + background: vars.$background-white; + padding: 1rem; + border-bottom: 1px solid vars.$nav-border; + + a { + text-decoration: none; + margin-right: 1rem; + color: vars.$primary; + + &:hover, + &:focus-visible { + text-decoration: underline; + outline: none; + } + } +} + +/* Dropdown menus */ +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-menu { + position: absolute; + top: 100%; + left: 0; + background: vars.$background-white; + border: 1px solid vars.$border-light; + border-radius: 4px; + list-style: none; + margin: 0; + padding: 0.25rem 0; + width: 100px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + z-index: 10; + + li { + padding: 0.5rem 1rem; + cursor: pointer; + font-size: 0.9rem; + + &:hover, + &:focus-visible { + background-color: vars.$dropdown-hover-bg; + outline: none; + } + } +} + +/* Pagination buttons */ +.pagination-button { + width: 1.8rem; + height: 1.8rem; + padding: 0; + border-radius: 4px; + border: 1px solid vars.$border-color; + background: vars.$background-white; + cursor: pointer; + transition: background-color 0.3s ease; + + display: flex; + align-items: center; + justify-content: center; + + font-size: 0.9rem; + line-height: 1; + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + + &:not(:disabled):hover, + &:not(:disabled):focus-visible { + background-color: vars.$dropdown-hover-bg; + outline: none; + } +} + +.pagination-button.active { + background-color: vars.$primary; + color: vars.$background-white; + border-color: vars.$primary; +} + +/* Modal overlay and content */ +.modal-overlay { + position: fixed; + top: 0; left: 0; + width: 100vw; + height: 100vh; + background: vars.$modal-overlay-bg; + display: flex; + justify-content: center; + align-items: center; + z-index: 1000; + padding: 1rem; + overflow-y: auto; +} + +.modal-content { + background: vars.$background-white; + padding: 1.5rem 2rem; + border-radius: 8px; + max-width: 400px; + width: 90%; + text-align: center; + box-shadow: 0 2px 12px rgba(0,0,0,0.3); + max-height: 90vh; + overflow-y: auto; + box-sizing: border-box; +} + +/* Modal buttons reuse global button classes for consistency */ +.modal-close-btn, +.btn-danger, +.btn-secondary { + padding: 0.5rem 1.25rem; + border: none; + font-weight: 600; + border-radius: 4px; + cursor: pointer; + transition: background-color 0.3s ease; + user-select: none; +} + +.modal-close-btn, +.btn-danger { + background-color: vars.$danger; + color: vars.$background-white; + + &:hover, + &:focus-visible { + background-color: vars.$danger-hover; + outline: none; + } +} + +.btn-secondary { + background-color: #888; + color: vars.$background-white; + + &:hover, + &:focus-visible { + background-color: #666; + outline: none; + } +} + +/* Form inputs and selects */ +input, +select, +textarea { + width: 100%; + padding: 0.5rem 0.75rem; + border: 1px solid vars.$border-color; + border-radius: 6px; + font-size: 1rem; + transition: border-color 0.3s ease, box-shadow 0.3s ease; + box-sizing: border-box; + + &:focus { + outline: none; + border-color: vars.$input-focus-color; + box-shadow: 0 0 6px vars.$input-focus-shadow; + } + + &[readonly], + &[disabled] { + background-color: #f5f5f5; + cursor: not-allowed; + color: vars.$text-color-light; + } +} + +input[readonly], +select[disabled] { + background-color: vars.$background-white !important; + color: vars.$text-color !important; + opacity: 1 !important; + cursor: default !important; +} + +input[readonly][required]:invalid { + box-shadow: none; + outline: none; +} + +/* Utility class to remove select arrows when readonly */ +.select-no-arrow { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; + padding-right: 1em; + background-image: none !important; + cursor: default; +} + +/* Validation error text */ +.validation-error { + color: #ef4444; + font-size: 0.75rem; + margin-top: 0.5rem; + margin-bottom: 0; +} + +/* Client picture styling for profile images */ +.client-picture { + display: flex; + justify-content: center; + + img { + width: 100%; + max-width: 160px; + height: auto; + aspect-ratio: 1 / 1; + object-fit: cover; + box-shadow: 0 0 8px rgba(0, 0, 0, 0.1); + background-color: #f0f0f0; + text-align: center; + line-height: 160px; + } +} + +/* Close button for modals */ +.close-btn { + background: transparent; + border: none; + font-size: 1.5rem; + line-height: 1; + cursor: pointer; + color: vars.$text-color-dark; + transition: color 0.3s ease; + padding: 0; + margin: 0; + + &:hover, + &:focus-visible { + color: vars.$input-focus-color; + outline: none; + } + + &:focus-visible { + outline: 2px solid vars.$input-focus-color; + outline-offset: 2px; + } +} + +/* Animations */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +/* Responsive adjustments */ +@media (max-width: 400px) { + .modal-content { + padding: 1.5rem; + } + + .modal-title { + font-size: 1.25rem; + } + + .button { + font-size: 0.9rem; + padding: 0.4rem 1rem; + max-width: 100px; + } +} diff --git a/src/views/Dashboard.vue b/src/views/Dashboard.vue new file mode 100644 index 0000000..d4746c9 --- /dev/null +++ b/src/views/Dashboard.vue @@ -0,0 +1,255 @@ + + + + + diff --git a/src/views/SignIn.vue b/src/views/SignIn.vue new file mode 100644 index 0000000..7ba92f3 --- /dev/null +++ b/src/views/SignIn.vue @@ -0,0 +1,193 @@ + + + + + diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..80fafe8 --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import { DefineComponent } from "vue"; + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..bee2b63 --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..e879ef1 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": false, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..bbcf80c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], +})