diff --git a/.env b/.env new file mode 100644 index 0000000..586081f --- /dev/null +++ b/.env @@ -0,0 +1,4 @@ +VITE_API_BASE_URL=http://localhost:5054 +VITE_API_FLAG=prod +VITE_GOOGLE_MAPS_API_KEY=stavitekljuclog + diff --git a/.github/workflows/frontend-ci.yml b/.github/workflows/frontend-ci.yml new file mode 100644 index 0000000..90f9401 --- /dev/null +++ b/.github/workflows/frontend-ci.yml @@ -0,0 +1,58 @@ +name: Frontend CI Checks + +# Controls when the workflow will run +on: + # Triggers the workflow on push events but only for the main branch + push: + branches: [develop] + # Triggers the workflow on pull request events targeting the main branch + pull_request: + branches: [develop] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +jobs: + build-and-lint: # You can name the job anything descriptive + runs-on: ubuntu-latest # Use a Linux runner + + strategy: + matrix: + node-version: [20.15.1] # Specify the Node.js version(s) you use + # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ + + steps: + # 1. Get the code from the repository + - name: Checkout code + uses: actions/checkout@v4 # Use the standard checkout action + + # 2. Setup Node.js environment + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + cache: 'npm' # Cache npm dependencies for faster builds + + # 3. Install dependencies (use 'ci' for clean installs in CI) + - name: Install dependencies + run: npm ci # 'npm ci' is generally preferred over 'npm install' in CI + + # 4. Run linters (if you have ESLint configured) + # Make sure you have a lint script in your package.json + # - name: Run linter (ESLint) + # run: npm run lint # Adjust if your lint script has a different name + + # 5. Run formatter check (if you use Prettier) + # Make sure you have a format check script in package.json (e.g., "prettier --check .") + # - name: Check formatting (Prettier) # Optional: Uncomment if using Prettier check + # run: npm run format:check # Adjust script name as needed + + # 6. Build the project (catches syntax errors, type errors if TS, build config issues) + - name: Build project + run: npm run build --if-present # Runs 'npm run build' if the script exists + + + # 7. Run tests (if you have tests configured - Vitest, Jest, etc.) + # Make sure you have a test script in package.json (e.g., "vitest run") + # - name: Run tests # Optional: Uncomment if using tests + # run: npm run test # Adjust script name as needed diff --git a/.gitignore b/.gitignore index fbddad8..efe72dd 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ yarn-debug.log* yarn-error.log* pnpm-debug.log* lerna-debug.log* +.vscode node_modules dist @@ -98,12 +99,7 @@ web_modules/ # Yarn Integrity file .yarn-integrity -# dotenv environment variable files -.env -.env.development.local -.env.test.local -.env.production.local -.env.local + # parcel-bundler cache (https://parceljs.org/) .cache diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..e2f27ef --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "jsxSingleQuote": true, + "trailingComma": "es5", + "tabWidth": 2, + "printWidth": 80 +} diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..ae9f4f8 --- /dev/null +++ b/TODO.md @@ -0,0 +1,28 @@ +## storeId i buyerId + +prioritet: 0 + +Provuci kroz komponente ideve a ne nazive da se poziv uradi uspjesno, +Naravno korisnku prikazujete nazive ali id se salje bekendu. + +## import/export csv i xlsx + +prioritet: 0 + +Nesto tu steka + +## active store reload + +prioritet: 1 +Ne updatea se vDOM + +## active product preko tackice + +prioritet: 1 +Fali categoryId + +## edit store + +prioritet: 2 + +nije dodana lista regiona i mjesta diff --git a/headers.toml b/headers.toml new file mode 100644 index 0000000..357cf65 --- /dev/null +++ b/headers.toml @@ -0,0 +1,7 @@ +[functions] + directory = "netlify/functions" # Or your functions directory + +[[redirects]] + from = "/api/netlify/directions" # Client will call this path + to = "/.netlify/functions/extRoute" + status = 200 \ No newline at end of file diff --git a/index.html b/index.html index 0c589ec..f072439 100644 --- a/index.html +++ b/index.html @@ -1,10 +1,22 @@ - +
-
- Edit src/App.jsx and save to test HMR
-
- Click on the Vite and React logos to learn more -
- > - ) +param.
+ */
+ paramToString(param) {
+ if (param == undefined || param == null) {
+ return '';
+ }
+ if (param instanceof Date) {
+ return param.toJSON();
+ }
+
+ return param.toString();
+ }
+
+ /**
+ * Builds full URL by appending the given path to the base URL and replacing path parameter place-holders with parameter values.
+ * NOTE: query parameters are not handled here.
+ * @param {String} path The path to append to the base URL.
+ * @param {Object} pathParams The parameter values to append.
+ * @returns {String} The encoded path with parameter values substituted.
+ */
+ buildUrl(path, pathParams) {
+ if (!path.match(/^\//)) {
+ path = '/' + path;
+ }
+
+ var url = this.basePath + path;
+ url = url.replace(/\{([\w-]+)\}/g, (fullMatch, key) => {
+ var value;
+ if (pathParams.hasOwnProperty(key)) {
+ value = this.paramToString(pathParams[key]);
+ } else {
+ value = fullMatch;
+ }
+
+ return encodeURIComponent(value);
+ });
+
+ return url;
+ }
+
+ /**
+ * Checks whether the given content type represents JSON.true if contentType represents JSON, otherwise false.
+ */
+ isJsonMime(contentType) {
+ return Boolean(contentType != null && contentType.match(/^application\/json(;.*)?$/i));
+ }
+
+ /**
+ * Chooses a content type from the given array, with JSON preferred; i.e. return JSON if included, otherwise return the first.
+ * @param {Array.true if param represents a file.
+ */
+ isFileParam(param) {
+ // fs.ReadStream in Node.js and Electron (but not in runtime like browserify)
+ if (typeof require === 'function') {
+ let fs;
+ try {
+ fs = require('fs');
+ } catch (err) {}
+ if (fs && fs.ReadStream && param instanceof fs.ReadStream) {
+ return true;
+ }
+ }
+
+ // Buffer in Node.js
+ if (typeof Buffer === 'function' && param instanceof Buffer) {
+ return true;
+ }
+
+ // Blob in browser
+ if (typeof Blob === 'function' && param instanceof Blob) {
+ return true;
+ }
+
+ // File in browser (it seems File object is also instance of Blob, but keep this for safe)
+ if (typeof File === 'function' && param instanceof File) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Normalizes parameter values:
+ * csv
+ * @const
+ */
+ CSV: ',',
+
+ /**
+ * Space-separated values. Value: ssv
+ * @const
+ */
+ SSV: ' ',
+
+ /**
+ * Tab-separated values. Value: tsv
+ * @const
+ */
+ TSV: '\t',
+
+ /**
+ * Pipe(|)-separated values. Value: pipes
+ * @const
+ */
+ PIPES: '|',
+
+ /**
+ * Native array. Value: multi
+ * @const
+ */
+ MULTI: 'multi'
+ };
+
+ /**
+ * Builds a string representation of an array-type actual parameter, according to the given collection format.
+ * @param {Array} param An array parameter.
+ * @param {module:ApiClient.CollectionFormatEnum} collectionFormat The array element separator strategy.
+ * @returns {String|Array} A string representation of the supplied collection, using the specified delimiter. Returns
+ * param as is if collectionFormat is multi.
+ */
+ buildCollectionParam(param, collectionFormat) {
+ if (param == null) {
+ return null;
+ }
+ switch (collectionFormat) {
+ case 'csv':
+ return param.map(this.paramToString).join(',');
+ case 'ssv':
+ return param.map(this.paramToString).join(' ');
+ case 'tsv':
+ return param.map(this.paramToString).join('\t');
+ case 'pipes':
+ return param.map(this.paramToString).join('|');
+ case 'multi':
+ //return the array directly as SuperAgent will handle it as expected
+ return param.map(this.paramToString);
+ default:
+ throw new Error('Unknown collection format: ' + collectionFormat);
+ }
+ }
+
+ /**
+ * Applies authentication headers to the request.
+ * @param {Object} request The request object created by a superagent() call.
+ * @param {Array.data will be converted to this type.
+ * @returns A value of the specified type.
+ */
+ deserialize(response, returnType) {
+ if (response == null || returnType == null || response.status == 204) {
+ return null;
+ }
+
+ // Rely on SuperAgent for parsing response body.
+ // See http://visionmedia.github.io/superagent/#parsing-response-bodies
+ var data = response.body;
+ if (data == null || (typeof data === 'object' && typeof data.length === 'undefined' && !Object.keys(data).length)) {
+ // SuperAgent does not always produce a body; use the unparsed response as a fallback
+ data = response.text;
+ }
+
+ return ApiClient.convertToType(data, returnType);
+ }
+
+ /**
+ * Callback function to receive the result of the operation.
+ * @callback module:ApiClient~callApiCallback
+ * @param {String} error Error message, if any.
+ * @param data The data returned by the service call.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * Invokes the REST service using the supplied settings and parameters.
+ * @param {String} path The base URL to invoke.
+ * @param {String} httpMethod The HTTP method to use.
+ * @param {Object.} pathParams A map of path parameters and their values.
+ * @param {Object.} queryParams A map of query parameters and their values.
+ * @param {Object.} headerParams A map of header parameters and their values.
+ * @param {Object.} formParams A map of form parameters and their values.
+ * @param {Object} bodyParam The value to pass as the request body.
+ * @param {Array.} authNames An array of authentication type names.
+ * @param {Array.} contentTypes An array of request MIME types.
+ * @param {Array.} accepts An array of acceptable response MIME types.
+ * @param {(String|Array|ObjectFunction)} returnType The required type to return; can be a string for simple types or the
+ * constructor for a complex type.
+ * @param {module:ApiClient~callApiCallback} callback The callback function.
+ * @returns {Object} The SuperAgent request object.
+ */
+ callApi(path, httpMethod, pathParams,
+ queryParams, headerParams, formParams, bodyParam, authNames, contentTypes, accepts,
+ returnType, callback) {
+
+ var url = this.buildUrl(path, pathParams);
+ var request = superagent(httpMethod, url);
+
+ // apply authentications
+ this.applyAuthToRequest(request, authNames);
+
+ // set query parameters
+ if (httpMethod.toUpperCase() === 'GET' && this.cache === false) {
+ queryParams['_'] = new Date().getTime();
+ }
+
+ request.query(this.normalizeParams(queryParams));
+
+ // set header parameters
+ request.set(this.defaultHeaders).set(this.normalizeParams(headerParams));
+
+ // set requestAgent if it is set by user
+ if (this.requestAgent) {
+ request.agent(this.requestAgent);
+ }
+
+ // set request timeout
+ request.timeout(this.timeout);
+
+ var contentType = this.jsonPreferredMime(contentTypes);
+ if (contentType) {
+ // Issue with superagent and multipart/form-data (https://github.com/visionmedia/superagent/issues/746)
+ if(contentType != 'multipart/form-data') {
+ request.type(contentType);
+ }
+ } else if (!request.header['Content-Type']) {
+ request.type('application/json');
+ }
+
+ if (contentType === 'application/x-www-form-urlencoded') {
+ request.send(new URLSearchParams(this.normalizeParams(formParams)));
+ } else if (contentType == 'multipart/form-data') {
+ var _formParams = this.normalizeParams(formParams);
+ for (var key in _formParams) {
+ if (_formParams.hasOwnProperty(key)) {
+ if (this.isFileParam(_formParams[key])) {
+ // file field
+ request.attach(key, _formParams[key]);
+ } else {
+ request.field(key, _formParams[key]);
+ }
+ }
+ }
+ } else if (bodyParam) {
+ request.send(bodyParam);
+ }
+
+ var accept = this.jsonPreferredMime(accepts);
+ if (accept) {
+ request.accept(accept);
+ }
+
+ if (returnType === 'Blob') {
+ request.responseType('blob');
+ } else if (returnType === 'String') {
+ request.responseType('string');
+ }
+
+ // Attach previously saved cookies, if enabled
+ if (this.enableCookies){
+ if (typeof window === 'undefined') {
+ this.agent.attachCookies(request);
+ }
+ else {
+ request.withCredentials();
+ }
+ }
+
+
+
+ request.end((error, response) => {
+ if (callback) {
+ var data = null;
+ if (!error) {
+ try {
+ data = this.deserialize(response, returnType);
+ if (this.enableCookies && typeof window === 'undefined'){
+ this.agent.saveCookies(response);
+ }
+ } catch (err) {
+ error = err;
+ }
+ }
+
+ callback(error, data, response);
+ }
+ });
+
+ return request;
+ }
+
+ /**
+ * Parses an ISO-8601 string representation of a date value.
+ * @param {String} str The date value as a string.
+ * @returns {Date} The parsed date object.
+ */
+ static parseDate(str) {
+ return new Date(str);
+ }
+
+ /**
+ * Converts a value to the specified type.
+ * @param {(String|Object)} data The data to convert, as a string or object.
+ * @param {(String|Array.|Object.|Function)} type The type to return. Pass a string for simple types
+ * or the constructor function for a complex type. Pass an array containing the type name to return an array of that type. To
+ * return an object, pass an object with one property whose name is the key type and whose value is the corresponding value type:
+ * all properties on data will be converted to this type.
+ * @returns An instance of the specified type or null or undefined if data is null or undefined.
+ */
+ static convertToType(data, type) {
+ if (data === null || data === undefined)
+ return data
+
+ switch (type) {
+ case 'Boolean':
+ return Boolean(data);
+ case 'Integer':
+ return parseInt(data, 10);
+ case 'Number':
+ return parseFloat(data);
+ case 'String':
+ return String(data);
+ case 'Date':
+ return ApiClient.parseDate(String(data));
+ case 'Blob':
+ return data;
+ default:
+ if (type === Object) {
+ // generic object, return directly
+ return data;
+ } else if (typeof type === 'function') {
+ // for model type like: User
+ return type.constructFromObject(data);
+ } else if (Array.isArray(type)) {
+ // for array type like: ['String']
+ var itemType = type[0];
+
+ return data.map((item) => {
+ return ApiClient.convertToType(item, itemType);
+ });
+ } else if (typeof type === 'object') {
+ // for plain object type like: {'String': 'Integer'}
+ var keyType, valueType;
+ for (var k in type) {
+ if (type.hasOwnProperty(k)) {
+ keyType = k;
+ valueType = type[k];
+ break;
+ }
+ }
+
+ var result = {};
+ for (var k in data) {
+ if (data.hasOwnProperty(k)) {
+ var key = ApiClient.convertToType(k, keyType);
+ var value = ApiClient.convertToType(data[k], valueType);
+ result[key] = value;
+ }
+ }
+
+ return result;
+ } else {
+ // for unknown type, return the data directly
+ return data;
+ }
+ }
+ }
+
+ /**
+ * Constructs a new map or array model from REST data.
+ * @param data {Object|Array} The REST data.
+ * @param obj {Object|Array} The target object or array.
+ */
+ static constructFromObject(data, obj, itemType) {
+ if (Array.isArray(data)) {
+ for (var i = 0; i < data.length; i++) {
+ if (data.hasOwnProperty(i))
+ obj[i] = ApiClient.convertToType(data[i], itemType);
+ }
+ } else {
+ for (var k in data) {
+ if (data.hasOwnProperty(k))
+ obj[k] = ApiClient.convertToType(data[k], itemType);
+ }
+ }
+ };
+}
+
+/**
+* The default API client implementation.
+* @type {module:ApiClient}
+*/
+ApiClient.instance = new ApiClient();
diff --git a/src/api/api.js b/src/api/api.js
new file mode 100644
index 0000000..c9a5687
--- /dev/null
+++ b/src/api/api.js
@@ -0,0 +1,1714 @@
+//import axios from "axios";
+import apiClientInstance from './apiClientInstance';
+import TestAuthApi from './api/TestAuthApi';
+import LoginDTO from './model/LoginDTO';
+import users from '../data/users';
+import stores from '../data/stores';
+import categories from '../data/categories';
+import products from '../data/products';
+import pendingUsers from '../data/pendingUsers.js';
+import axios from 'axios';
+import * as XLSX from 'xlsx';
+import ads from '../data/ads.js';
+import sha256 from 'crypto-js/sha256';
+import { format } from 'date-fns';
+//import { GET } from 'superagent';
+const baseApiUrl = import.meta.env.VITE_API_BASE_URL;
+const API_FLAG = import.meta.env.VITE_API_FLAG;
+const API_ENV_DEV = 'dev';
+const googleMapsApiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
+
+console.log('Mock', API_FLAG);
+console.log('api ', baseApiUrl);
+
+const apiSetAuthHeader = () => {
+ const token = localStorage.getItem('token');
+
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+};
+
+// ----------------------
+// AUTH - Prijava korisnika
+// ----------------------
+
+export const apiLoginUserAsync = async (username, password) => {
+ if (API_FLAG == API_ENV_DEV) {
+ const testAuthApi = new TestAuthApi(apiClientInstance);
+ const loginPayload = new LoginDTO();
+ loginPayload.username = username;
+ loginPayload.password = password;
+
+ console.log('Attempting login via TestAuthApi for:', username);
+
+ localStorage.setItem('auth', true);
+ } else {
+ const ret = await axios.post(`${baseApiUrl}/api/Auth/login`, {
+ email: username,
+ email: username,
+ password: password,
+ app: 'Admin',
+ });
+ const token = ret.data.token;
+ localStorage.setItem('auth', true);
+ localStorage.setItem('token', token);
+ }
+};
+
+// ----------------------
+// PENDING USERS - Neodobreni korisnici
+// ----------------------
+
+export const apiFetchPendingUsersAsync = async () => {
+ if (API_ENV_DEV == API_FLAG) {
+ try {
+ return pendingUsers;
+ } catch (error) {
+ console.error('Greška pri dohvaćanju korisnika:', error);
+ throw error;
+ }
+ } else {
+ const token = localStorage.getItem('token');
+
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+ const users = await axios.get(`${baseApiUrl}/api/Admin/users`);
+ return users.data.filter((u) => !u.isApproved);
+ }
+};
+
+export const apiApproveUserAsync = async (userId) => {
+ if (API_ENV_DEV == API_FLAG) {
+ try {
+ // pronađi korisnika u "pendingUsers" nizu i označi ga kao odobrenog
+ const userIndex = pendingUsers.findIndex((user) => user.id === userId);
+ if (userIndex !== -1) {
+ const user = pendingUsers[userIndex];
+ user.isApproved = true;
+ // premjesti korisnika iz pendingUsers u users
+ users.push(user);
+ pendingUsers.splice(userIndex, 1);
+ return user;
+ } else {
+ throw new Error('User not found in pending users.');
+ }
+ } catch (error) {
+ console.error('Error approving user:', error);
+ throw error;
+ }
+ } else {
+ const token = localStorage.getItem('token');
+
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+ return axios.post(`${baseApiUrl}/api/Admin/users/approve`, {
+ userId: userId,
+ });
+ }
+};
+
+// ----------------------
+// USER MANAGEMENT
+// ----------------------
+
+export const apiFetchApprovedUsersAsync = async () => {
+ if (API_ENV_DEV == API_FLAG) {
+ try {
+ // dohvati users iz niza koji su odobreni
+ return users.filter((user) => user.isApproved);
+ } catch (error) {
+ console.error('Greška pri dohvaćanju odobrenih korisnika:', error);
+ throw error;
+ }
+ } else {
+ apiSetAuthHeader();
+ const users = await axios.get(`${baseApiUrl}/api/Admin/users`);
+ return users.data
+ .filter((u) => u.isApproved)
+ .filter(
+ (u) =>
+ (u.roles && u.roles != 'Admin') || (u.roles && u.roles[0] != 'Admin')
+ );
+ }
+};
+
+export const apiCreateUserAsync = async (newUserPayload) => {
+ if (API_ENV_DEV == API_FLAG) {
+ try {
+ // dodaj novog korisnika u niz "users"
+ const newUser = {
+ email: newUserPayload.email,
+ userName: newUserPayload.userName,
+ password: newUserPayload.password,
+ id: users.length + 1,
+ isApproved: false,
+ roles: [newUserPayload.role],
+ };
+ //users.push(newUser);
+ pendingUsers.push(newUser);
+ return { data: newUser };
+ } catch (error) {
+ console.error('Greška pri kreiranju korisnika:', error);
+ throw error;
+ }
+ } else {
+ apiSetAuthHeader();
+ return axios.post(`${baseApiUrl}/api/Admin/users/create`, {
+ userName: newUserPayload.userName,
+ email: newUserPayload.email,
+ password: newUserPayload.password,
+ role: newUserPayload.role,
+ });
+ }
+};
+
+export const apiDeleteUserAsync = async (userId) => {
+ if (API_ENV_DEV == API_FLAG) {
+ try {
+ // Pronađi korisnika u "users" i ukloni ga iz niza
+ const userIndex = users.findIndex((user) => user.id === userId);
+ if (userIndex !== -1) {
+ const user = users[userIndex];
+ users.splice(userIndex, 1);
+ return user;
+ } else {
+ throw new Error('User not found.');
+ }
+ } catch (error) {
+ console.error('Error deleting user:', error);
+ throw error;
+ }
+ } else {
+ apiSetAuthHeader();
+ return axios.delete(`${baseApiUrl}/api/Admin/user/${userId}`);
+ }
+};
+
+/*
+
+// ----------------------
+// PENDING USERS - Neodobreni korisnici
+// ----------------------
+
+
+
+
+
+export const apiFetchPendingUsersAsync = async () => {
+ try {
+ const response = await axios.get('/api/Admin/users');
+ return response.data.filter((user) => !user.isApproved);
+ } catch (error) {
+ console.error("Greška pri dohvaćanju korisnika:", error);
+ throw error;
+ }
+};
+
+export const apiApproveUserAsync = async (userId) => {
+ try {
+ const response = await axios.post('/api/Admin/users/approve', { userId });
+ return response.data;
+ } catch (error) {
+ console.error("Error approving user:", error);
+ throw error;
+ }
+};
+
+
+
+
+
+// ----------------------
+// USER MANAGEMENT
+// ----------------------
+
+
+
+
+export const apiFetchApprovedUsersAsync = async () => {
+ try {
+ const response = await axios.get('/api/Admin/users');
+ return response.data.filter((user) => user.isApproved && user.roles[0] !== "Admin");
+ } catch (error) {
+ console.error("Greška pri dohvaćanju odobrenih korisnika:", error);
+ throw error;
+ }
+};
+
+export const apiCreateUserAsync = async (newUserPayload) => {
+ try {
+ const response = await axios.post('/api/Admin/users/create', newUserPayload);
+ return response.data;
+ } catch (error) {
+ console.error("Greška pri kreiranju korisnika:", error);
+ throw error;
+ }
+};
+
+export const apiDeleteUserAsync = async (userId) => {
+ try {
+ const response = await axios.delete(`/api/Admin/user/${userId}`);
+ return response.data;
+ } catch (error) {
+ console.error("Error deleting user:", error);
+ throw error;
+ }
+};*/
+
+// {
+// "name": "aa",
+// "price": "1",
+// "weight": "1",
+// "weightunit": "kg",
+// "volume": "1",
+// "volumeunit": "L",
+// "productcategoryid": 1,
+// "storeId": 2,
+// "photos": [
+// {
+// "path": "./maca.jpg",
+// "relativePath": "./maca.jpg"
+// }
+// ]
+// }
+
+/**
+ * Fetches products for a specific store
+ * @param {number} storeId - ID of the store
+ * @returns {Promise<{status: number, data: Array}>} List of products
+ */
+export const apiGetStoreProductsAsync = async (storeId, categoryId = null) => {
+ if (API_ENV_DEV === API_FLAG) {
+ return {
+ status: 200,
+ data: products.filter((p) => p.storeId === storeId),
+ };
+ } else {
+ try {
+ apiSetAuthHeader();
+ const params = new URLSearchParams();
+ params.append('storeId', storeId);
+ if (categoryId !== null) {
+ params.append('categoryId', categoryId);
+ }
+
+ const response = await axios.get(
+ `${baseApiUrl}/api/Catalog/products?${params.toString()}`
+ );
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Error fetching store products:', error);
+ return { status: error.response?.status || 500, data: [] };
+ }
+ }
+};
+
+/**
+ * Creates a new product
+ * @param {Object} productData - Product data to create
+ * @returns {Promise<{status: number, data: Object}>} Created product
+ */
+export const apiCreateProductAsync = async (productData) => {
+ if (API_ENV_DEV === API_FLAG) {
+ try {
+ const newProduct = {
+ id: products.length + 1,
+ ...productData,
+ };
+ products.push(newProduct);
+ return { status: 201, data: newProduct };
+ } catch (error) {
+ console.error('Product creation failed:', error);
+ return { status: 500, data: null };
+ }
+ } else {
+ console.log('TEST: ', productData);
+ try {
+ const formData = new FormData();
+ const price = productData.retailPrice || productData.price;
+ formData.append('RetailPrice', String(price ?? 0));
+ formData.append(
+ 'ProductCategoryId',
+ String(productData.productcategoryid || productData.productCategory)
+ );
+ formData.append(
+ 'WholesalePrice',
+ String(productData.wholesalePrice ?? 0)
+ );
+ formData.append('Name', productData.name);
+ formData.append('Weight', String(productData.weight ?? 0));
+ formData.append('Volume', String(productData.volume ?? 0));
+ formData.append('WeightUnit', productData.weightUnit ?? '');
+ formData.append('StoreId', String(productData.storeId));
+ formData.append('VolumeUnit', productData.volumeUnit ?? '');
+
+ if (productData.photos?.length > 0) {
+ productData.photos.forEach((file) => {
+ if (file instanceof File) {
+ formData.append('Files', file, file.name);
+ }
+ });
+ }
+
+ const response = await axios.post(
+ `${baseApiUrl}/api/Admin/products/create`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ }
+ );
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Product creation failed:', error);
+ return { status: error.response?.status || 500, data: null };
+ }
+ }
+};
+
+/**
+ * Updates an existing product
+ * @param {Object} productData - Product data to update
+ * @returns {Promise<{status: number, data: Object}>} Updated product
+ */
+export const apiUpdateProductAsync = async (productData) => {
+ apiSetAuthHeader();
+
+ try {
+ const payload = {
+ name: productData.name,
+ productCategoryId: Number(productData.productCategoryId),
+ retailPrice: Number(productData.retailPrice ?? 0),
+ wholesaleThreshold: Number(productData.wholesaleThreshold ?? 0),
+ wholesalePrice: Number(productData.wholesalePrice ?? 0),
+ weight: Number(productData.weight ?? 0),
+ weightUnit: productData.weightUnit ?? 'kg',
+ volume: Number(productData.volume ?? 0),
+ volumeUnit: productData.volumeUnit ?? 'L',
+ storeId: Number(productData.storeId),
+ isActive: Boolean(productData.isActive),
+ files: productData.files ?? [],
+ };
+
+ console.log('📦 Product update payload:', payload);
+
+ const response = await axios.put(
+ `${baseApiUrl}/api/Admin/products/${productData.id}`,
+ payload,
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ );
+
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('❌ Error updating product:', error.response?.data || error);
+ return { status: error.response?.status || 500, data: null };
+ }
+};
+
+/**
+ * Deletes a product
+ * @param {number} productId - ID of the product to delete
+ * @returns {Promise<{status: number, data: Object}>} Deletion result
+ */
+export const apiDeleteProductAsync = async (productId) => {
+ if (API_ENV_DEV === API_FLAG) {
+ const index = products.findIndex((p) => p.id === productId);
+ if (index !== -1) {
+ products.splice(index, 1);
+ return { status: 204, data: null };
+ }
+ return { status: 404, data: null };
+ } else {
+ try {
+ const response = await axios.delete(
+ `${baseApiUrl}/api/Admin/products/${productId}`
+ );
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Error deleting product:', error);
+ return { status: error.response?.status || 500, data: null };
+ }
+ }
+};
+
+export const apiGetProductCategoriesAsync = async () => {
+ if (API_ENV_DEV == API_FLAG) {
+ return categories.filter((cat) => cat.type === 'product');
+ } else {
+ apiSetAuthHeader();
+ const res = await axios.get(`${baseApiUrl}/api/Admin/categories`);
+ return res.data;
+ }
+};
+
+export const apiGetStoreCategoriesAsync = async () => {
+ if (API_ENV_DEV == API_FLAG) {
+ return categories.filter((cat) => cat.type === 'store');
+ } else {
+ apiSetAuthHeader();
+ const res = await axios.get(`${baseApiUrl}/api/Admin/store/categories`);
+ return res.data;
+ }
+};
+
+// Get store details
+export const apiGetStoreByIdAsync = async (storeId) => {
+ if (API_ENV_DEV == API_FLAG) {
+ // izbrisati naknadno
+ const mockStore = {
+ id: storeId,
+ name: 'Nova Market',
+ description: 'Brza i kvalitetna dostava proizvoda.',
+ isOnline: true,
+ createdAt: '2024-01-01',
+ products: [],
+ };
+
+ return stores.filter((trgovina) => trgovina.id === storeId);
+ } else {
+ apiSetAuthHeader();
+ const store = await axios.get(`${baseApiUrl}/api/Admin/stores/${storeId}`);
+ return store.data;
+ }
+};
+
+export const apiUpdateStoreAsync = async (store) => {
+ if (API_ENV_DEV === API_FLAG) {
+ const index = stores.indexOf((st) => store.name == st.name);
+ stores[index] = {
+ ...store,
+ };
+ return new Promise((resolve) =>
+ setTimeout(() => resolve({ success: true, data: store }), 500)
+ );
+ } else {
+ apiSetAuthHeader();
+ return axios.put(`${baseApiUrl}/api/Admin/store/${store.id}`, {
+ id: store.id,
+ name: store.name,
+ address: store.address,
+ categoryId: store.categoryId,
+ description: store.description,
+ isActive: store.isActive,
+ tax: store.tax
+ });
+ }
+};
+
+// Get all stores
+export const apiGetAllStoresAsync = async () => {
+ if (API_ENV_DEV == API_FLAG) {
+ //izbrisati poslije
+ return stores;
+ //return new Promise((resolve) => setTimeout(() => resolve({stores}), 500));
+ } else {
+ apiSetAuthHeader();
+ const stores = await axios.get(`${baseApiUrl}/api/Admin/stores`);
+ return stores.data;
+ }
+};
+
+export const apiGetMonthlyStoreRevenueAsync = async (id) => {
+ apiSetAuthHeader();
+ const now = new Date();
+ const firstDayOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); // ✅ define it here
+ const lastDayOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); // ✅ and here
+ const from = format(firstDayOfMonth, 'yyyy-MM-dd');
+ const to = format(lastDayOfMonth, 'yyyy-MM-dd');
+
+ const rev = await axios.get(`${baseApiUrl}/api/Admin/store/${id}/income?from=${from}&to=${to}`);
+ console.log(rev);
+ return rev.data;
+
+};
+// DELETE product category
+export const apiDeleteProductCategoryAsync = async (categoryId) => {
+ if (API_ENV_DEV === API_FLAG) {
+ const rez = categories.filter((cat) => cat.id == categoryId);
+ const index = categories.indexOf(rez);
+ if (index > -1) {
+ categories.splice(index, 1);
+ console.log('deleted');
+ }
+ return new Promise((resolve) =>
+ setTimeout(() => resolve({ success: true, deletedId: categoryId }), 500)
+ );
+ } else {
+ apiSetAuthHeader();
+ return axios.delete(`${baseApiUrl}/api/Admin/categories/${categoryId}`);
+ }
+};
+
+// DELETE store category
+export const apiDeleteStoreCategoryAsync = async (categoryId) => {
+ if (API_ENV_DEV === API_FLAG) {
+ const rez = categories.filter((cat) => cat.id == categoryId);
+ const index = categories.indexOf(rez);
+ if (index > -1) {
+ categories.splice(index, 1);
+ console.log('deleted');
+ }
+ return new Promise((resolve) =>
+ setTimeout(() => resolve({ success: true, deletedId: categoryId }), 500)
+ );
+ } else {
+ apiSetAuthHeader();
+ return axios.delete(`${baseApiUrl}/api/Admin/store/category/${categoryId}`);
+ }
+};
+
+export const apiAddProductCategoryAsync = async (name) => {
+ if (API_ENV_DEV === API_FLAG) {
+ try {
+ const newCategory = {
+ id: categories.length + 1,
+ name: name,
+ type: 'product',
+ };
+ categories.push(newCategory);
+ return { data: newCategory };
+ } catch (error) {
+ console.log('Error pri kreiranju kategorije proizvoda!');
+ throw error;
+ }
+ //return new Promise((resolve) =>
+ //setTimeout(
+ //() => resolve({ success: true, data: { id: Date.now(), name } }),
+ //500
+ //)
+ //);
+ } else {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.post(`${baseApiUrl}/api/Admin/categories`, {
+ name,
+ });
+ return { success: true, data: res.data };
+ } catch (err) {
+ console.error('Error creating product category:', err);
+ return { success: false };
+ }
+ }
+};
+
+export const apiAddStoreCategoryAsync = async (name) => {
+ if (API_ENV_DEV === API_FLAG) {
+ try {
+ const newCategory = {
+ id: categories.length + 1,
+ name: name,
+ type: 'store',
+ };
+ categories.push(newCategory);
+ return newCategory;
+ } catch (error) {
+ console.log('Error pri kreiranju kategorije trgovine!');
+ throw error;
+ }
+ // return new Promise((resolve) =>
+ // setTimeout(
+ // () => resolve({ success: true, data: { id: Date.now(), name } }),
+ // 500
+ // )
+ //);
+ } else {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.post(
+ `${baseApiUrl}/api/Admin/store/categories/create`,
+ { name }
+ );
+ return { success: res.status < 400, data: res.data };
+ } catch (err) {
+ console.error('Error creating store category:', err);
+ return { success: false };
+ }
+ }
+};
+
+export const apiUpdateProductCategoryAsync = async (updatedCategory) => {
+ if (API_ENV_DEV === API_FLAG) {
+ const index = categories.findIndex((cat) => cat.id === updatedCategory);
+ categories[index] = {
+ ...updatedCategory,
+ };
+ //???
+ }
+ apiSetAuthHeader();
+ try {
+ const response = await axios.put(
+ `${baseApiUrl}/api/Admin/categories/${updatedCategory.id}`,
+ { name: updatedCategory.name }
+ );
+ return { success: true, data: response.data };
+ } catch (error) {
+ console.error('Error updating product category:', error);
+ return { success: false, message: error.message };
+ }
+};
+
+export const apiUpdateStoreCategoryAsync = async (updatedCategory) => {
+ if (API_ENV_DEV === API_FLAG) {
+ const index = categories.findIndex(
+ (cat) => cat.name === updatedCategory.name
+ );
+ categories[index] = {
+ ...updatedCategory,
+ };
+ return { success: true, data: response.data };
+ } else {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.put(
+ `${baseApiUrl}/api/Admin/store/category/${updatedCategory.id}`,
+ { name: updatedCategory.name }
+ );
+ return { success: true, data: response.data };
+ } catch (error) {
+ console.error('Error updating store category:', error);
+ return { success: false, message: error.message };
+ }
+ }
+};
+
+export const apiAddStoreAsync = async (newStore) => {
+ if (API_ENV_DEV === API_FLAG) {
+ return {
+ status: 201,
+ data: { ...newStore, id: Date.now() },
+ };
+ } else {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.post(
+ `${baseApiUrl}/api/Admin/store/create`,
+ {
+ name: newStore.name,
+ categoryId: newStore.categoryid,
+ address: newStore.address,
+ description: newStore.description,
+ placeId: newStore.placeId,
+ }
+ );
+ return response;
+ } catch (error) {
+ console.error('Greška pri kreiranju prodavnice:', error);
+ return { success: false };
+ }
+ }
+};
+
+export const apiDeleteStoreAsync = async (storeId) => {
+ if (API_ENV_DEV === API_FLAG) {
+ const rez = stores.find((store) => store.id == storeId);
+ const index = stores.indexOf(rez);
+ //console.log(index);
+ if (index > -1) {
+ stores.splice(index, 1);
+ console.log(storeId);
+ console.log(rez.id);
+ }
+ return new Promise((resolve) =>
+ setTimeout(() => resolve({ success: true, deletedId: storeId }), 500)
+ );
+ } else {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.delete(
+ `${baseApiUrl}/api/Admin/store/${storeId}`
+ );
+ return { success: res.status === 204 };
+ } catch (error) {
+ console.error('Greška pri brisanju prodavnice:', error);
+ return { success: false };
+ }
+ }
+};
+
+// Mock ažuriranje korisnika
+export const apiUpdateUserAsync = async (updatedUser) => {
+ if (API_ENV_DEV == API_FLAG) {
+ const index = users.findIndex((us) => us.id === updatedUser.id);
+ users[index] = {
+ ...updatedUser,
+ };
+ return new Promise((resolve) =>
+ setTimeout(() => resolve({ success: true, updatedUser }), 500)
+ );
+ } else {
+ apiSetAuthHeader();
+ return axios.put(`${baseApiUrl}/api/Admin/users/update`, {
+ userName: updatedUser.email,
+ id: updatedUser.id,
+ role: updatedUser.roles[0],
+ lastActive: updatedUser.isActive,
+ isApproved: updatedUser.isApproved,
+ email: updatedUser.email,
+ });
+ }
+};
+
+// Mock promjena statusa korisnika (Online/Offline)
+export const apiToggleUserAvailabilityAsync = async (userId, currentStatus) => {
+ if (API_ENV_DEV == API_FLAG) {
+ const newStatus = currentStatus === 'Online' ? 'false' : 'true';
+ const index = users.find((us) => us.id == userId);
+ users[index].availability = newStatus;
+ return new Promise((resolve) =>
+ setTimeout(
+ () => resolve({ success: true, availability: newStatus }),
+ 10000
+ )
+ );
+ } else {
+ apiSetAuthHeader();
+ console.log({
+ userId: userId,
+ activationStatus: currentStatus,
+ });
+ return axios.post(`${baseApiUrl}/api/Admin/users/activate`, {
+ userId: userId,
+ activationStatus: currentStatus,
+ });
+ }
+};
+
+/**
+ * Simulira export proizvoda u Excel formatu.
+ * @returns {Promise<{status: number, data: Blob}>} Axios-like odgovor sa blobom Excel fajla
+ */
+export const apiExportProductsToExcelAsync = async (storeId) => {
+ if (API_ENV_DEV == API_FLAG) {
+ const mockProducts = [
+ { name: 'Product 1', price: 100, description: 'Description 1' },
+ { name: 'Product 2', price: 200, description: 'Description 2' },
+ { name: 'Product 3', price: 300, description: 'Description 3' },
+ ];
+
+ const ws = XLSX.utils.json_to_sheet(mockProducts);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, 'Products');
+
+ const excelData = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
+
+ const blob = new Blob([excelData], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+
+ return {
+ status: 200,
+ data: blob,
+ };
+ } else {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.get(`${baseApiUrl}/api/Admin/products`, {
+ params: { storeId },
+ });
+
+ console.log('Dobio odgovor:', response.data);
+ const products = response.data;
+
+ // Pretvori podatke u Excel format
+ const flattenedProducts = products.map((product) => ({
+ ...product,
+ productCategory: product.productCategory?.id ?? null,
+ photos: product.photos || '',
+ }));
+
+ const ws = XLSX.utils.json_to_sheet(flattenedProducts);
+ const wb = XLSX.utils.book_new();
+ XLSX.utils.book_append_sheet(wb, ws, 'Products');
+ const excelData = XLSX.write(wb, { bookType: 'xlsx', type: 'array' });
+
+ const blob = new Blob([excelData], {
+ type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ });
+
+ return { status: 200, data: blob };
+ } catch (error) {
+ return {
+ status: error.response?.status || 500,
+ data: error.response?.data || error,
+ };
+ }
+ }
+};
+
+/**
+ * Simulira export proizvoda u CSV formatu.
+ * @returns {Promise<{status: number, data: Blob}>} Axios-like odgovor sa blobom CSV fajla
+ */
+export const apiExportProductsToCSVAsync = async (storeId) => {
+ if (API_ENV_DEV == API_FLAG) {
+ const csvContent =
+ 'Product ID,Product Name,Price\n1,Product A,10.99\n2,Product B,19.99';
+ const blob = new Blob([csvContent], { type: 'text/csv' });
+
+ return {
+ status: 200,
+ data: blob,
+ };
+ } else {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.get(`${baseApiUrl}/api/Admin/products`, {
+ params: { storeId },
+ });
+ const products = response.data;
+
+ // Pretvaranje objekata u CSV string
+ const flattenedProducts = products.map((product) => ({
+ ...product,
+ productCategory: product.productCategory?.id ?? null,
+ photos: product.photos || '',
+ }));
+
+ const header = Object.keys(flattenedProducts[0] || {}).join(',');
+ const rows = flattenedProducts.map((product) =>
+ Object.values(product).join(',')
+ );
+
+ const csvContent = [header, ...rows].join('\n');
+
+ const blob = new Blob([csvContent], { type: 'text/csv' });
+
+ return { status: 200, data: blob };
+ } catch (error) {
+ return {
+ status: error.response?.status || 500,
+ data: error.response?.data || error,
+ };
+ }
+ }
+};
+
+export const apiFetchOrdersAsync = async () => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(`${baseApiUrl}/api/Admin/order`);
+ const orders = res.data;
+ return orders.map((order) => ({
+ id: order.id,
+ status: order.status,
+ buyerName: order.buyerId,
+ storeName: order.storeId,
+ buyerId: order.buyerId,
+ storeId: order.storeId,
+ addressId: order.addressId,
+ createdAt: order.time,
+ totalPrice: order.total,
+ isCancelled: order.status === 1,
+ products: order.orderItems,
+ }));
+ } catch (err) {
+ console.error('Error fetching orders:', err);
+ return [];
+ }
+};
+
+const mapOrderStatus = (code) => {
+ return (
+ {
+ 0: 'active',
+ 1: 'cancelled',
+ 2: 'requested',
+ 3: 'confirmed',
+ 4: 'ready',
+ 5: 'sent',
+ 6: 'delivered',
+ }[code] || 'unknown'
+ );
+};
+
+export const apiFetchGeographyAsync = async () => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(`${baseApiUrl}/api/Geography/geography`, {
+ headers: {
+ Accept: 'application/json',
+ },
+ });
+ return res.data;
+ } catch (error) {
+ console.error('Error fetching geography data:', error);
+ return { regions: [], places: [] };
+ }
+};
+
+export const apiDeleteOrderAsync = async (orderId) => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.delete(`${baseApiUrl}/api/Admin/order/${orderId}`);
+ return { status: res.status };
+ } catch (err) {
+ console.error('Error deleting order:', err);
+ return { status: err.response?.status || 500 };
+ }
+};
+
+export const apiUpdateOrderAsync = async (orderId, payload) => {
+ apiSetAuthHeader();
+
+ try {
+ console.log(payload);
+ const response = await axios.put(
+ `${baseApiUrl}/api/Admin/order/update/${orderId}`,
+ {
+ buyerId: String(payload.buyerId),
+ storeId: Number(payload.storeId),
+ status: String(payload.status),
+ time: new Date(payload.time).toISOString(),
+ total: Number(payload.total),
+ orderItems: payload.orderItems.map((item) => ({
+ id: Number(item.id),
+ productId: Number(item.productId),
+ price: Number(item.price),
+ quantity: Number(item.quantity),
+ })),
+ }
+ );
+
+ return { success: response.status === 204 };
+ } catch (error) {
+ console.error('Error updating order:', error.response?.data || error);
+ return { success: false, message: error.message };
+ }
+};
+
+export const apiUpdateOrderStatusAsync = async (orderId, newStatus) => {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.put(
+ `${baseApiUrl}/api/Admin/order/update/status/${orderId}`,
+ {
+ newStatus: newStatus === 'active' ? 1 : 0,
+ }
+ );
+ return { success: response.status === 204 };
+ } catch (error) {
+ console.error(
+ 'Error updating order status:',
+ error.response?.data || error
+ );
+ return { success: false, message: error.message };
+ }
+};
+
+export const apiFetchAllUsersAsync = async () => {
+ if (API_ENV_DEV == API_FLAG) {
+ try {
+ return pendingUsers;
+ } catch (error) {
+ console.error('Greška pri dohvaćanju korisnika:', error);
+ throw error;
+ }
+ } else {
+ const token = localStorage.getItem('token');
+
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+ const users = await axios.get(`${baseApiUrl}/api/Admin/users`);
+ return users;
+ }
+};
+/**
+ * Pretvara listu stringova (["Search", "Buy"]) u bit-flag broj*/
+const convertTriggersToBitFlag = (triggers) => {
+ const triggerMap = {
+ search: 1,
+ buy: 2,
+ view: 4,
+ };
+
+ if (!Array.isArray(triggers)) return 0;
+
+ return triggers.reduce((acc, trigger) => {
+ const lowerTrigger = trigger.toLowerCase();
+ return acc | (triggerMap[lowerTrigger] || 0);
+ }, 0);
+};
+export const apiCreateAdAsync = async (adData) => {
+ try {
+ apiSetAuthHeader();
+ const formData = new FormData();
+
+ // Osnovni podaci
+ formData.append('SellerId', String(adData.sellerId));
+ formData.append('StartTime', new Date(adData.startTime).toISOString());
+ formData.append('EndTime', new Date(adData.endTime).toISOString());
+ formData.append('ClickPrice', parseFloat(adData.clickPrice));
+ formData.append('ViewPrice', parseFloat(adData.viewPrice));
+ formData.append('ConversionPrice', parseFloat(adData.conversionPrice));
+ formData.append('AdType', adData.AdType);
+ if (Array.isArray(adData.Triggers)) {
+ adData.Triggers.forEach((item, index) => {
+ formData.append(`Triggers[${index}]`, String(item));
+ });
+ }
+ if (Array.isArray(adData.AdData)) {
+ console.log(adData.AdData[0].StoreLink);
+ adData.AdData.forEach((item, index) => {
+ formData.append(
+ `AdDataItems[${index}].storeId`,
+ String(item.StoreLink)
+ );
+ formData.append(
+ `AdDataItems[${index}].productId`,
+ String(item.ProductLink)
+ );
+ formData.append(
+ `AdDataItems[${index}].description`,
+ item.Description ?? ''
+ );
+ if (item.Image) {
+ formData.append(
+ `AdDataItems[${index}].imageFile`,
+ item.Image,
+ item.Image.name
+ );
+ }
+ });
+ }
+
+ //ispis
+ for (const [key, val] of formData.entries()) {
+ console.log(key, val);
+ }
+ console.log(formData);
+ const response = await axios.post(
+ `${baseApiUrl}/api/AdminAnalytics/advertisements`,
+ formData,
+ { headers: { 'Content-Type': 'multipart/form-data' } }
+ );
+
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Advertisement creation failed:', error);
+ return { status: error.response?.status || 500, data: null };
+ }
+};
+
+/**
+ * Fetches all advertisements
+ * @returns {Promise<{status: number, data: Array}>} lista reklama
+ */
+export const apiGetAllAdsAsync = async () => {
+ if (API_ENV_DEV === API_FLAG) {
+ // Return mock data for development
+ const mockAds = ads;
+
+ return { status: 200, data: mockAds };
+ } else {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.get(
+ `${baseApiUrl}/api/AdminAnalytics/advertisements`
+ );
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Error fetching advertisements:', error);
+ return { status: error.response?.status || 500, data: [] };
+ }
+ }
+};
+
+/**
+ * Deletes an advertisement
+ * @param {number} adId - ID reklame koja se brise
+ * @returns {Promise<{status: number, data: Object}>}
+ */
+export const apiDeleteAdAsync = async (adId) => {
+ if (API_ENV_DEV === API_FLAG) {
+ // Mock deletion for development
+ return { status: 204, data: null };
+ } else {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.delete(
+ `${baseApiUrl}/api/AdminAnalytics/advertisements/${adId}`
+ );
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Error deleting advertisement:', error);
+ return { status: error.response?.status || 500, data: null };
+ }
+ }
+};
+
+/**
+ * Updates an existing advertisement
+ * @param {Object} adData - Advertisement data to update
+ * @returns {Promise<{status: number, data: Object}>} Updated advertisement
+ */
+/*
+export const apiUpdateAdAsync = async (adData) => {
+ if (API_ENV_DEV === API_FLAG) {
+ // Mock update for development
+ return {
+ status: 200,
+ data: {
+ ...adData,
+ startTime: new Date(adData.startTime).toISOString(),
+ endTime: new Date(adData.endTime).toISOString(),
+ },
+ };
+ } else {
+ apiSetAuthHeader();
+ try {
+ const formData = new FormData();
+ formData.append('id', adData.id);
+ formData.append('sellerId', adData.sellerId);
+ formData.append('startTime', new Date(adData.startTime).toISOString());
+ formData.append('endTime', new Date(adData.endTime).toISOString());
+
+ // Handle the AdData array
+ adData.AdData.forEach((item, index) => {
+ formData.append(`AdDataItems[${index}].Description`, item.Description);
+ formData.append(`AdDataItems[${index}].ProductLink`, item.ProductLink);
+ formData.append(`AdDataItems[${index}].StoreLink`, item.StoreLink);
+ // Handle image file if it exists
+ if (item.Image instanceof File) {
+ formData.append(
+ `AdData[${index}].Image`,
+ item.Image,
+ item.Image.name
+ );
+ } else if (typeof item.Image === 'string') {
+ formData.append(`AdData[${index}].ImagePath`, item.Image);
+ }
+ });
+
+ const response = await axios.put(
+ `${baseApiUrl}/api/AdminAnalytics/advertisements/${adData.id}`,
+ formData,
+ {
+ headers: {
+ 'Content-Type': 'multipart/form-data',
+ },
+ }
+ );
+
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Advertisement update failed:', error);
+ return { status: error.response?.status || 500, data: null };
+ }
+ }
+};
+*/
+
+export const apiUpdateAdAsync = async (advertisementId, adData) => {
+ try {
+ apiSetAuthHeader();
+
+ const formData = new FormData();
+ formData.append('StartTime', new Date(adData.startTime).toISOString());
+ formData.append('EndTime', new Date(adData.endTime).toISOString());
+ formData.append('IsActive', adData.isActive);
+ formData.append('AdType', adData.adType);
+ formData.append('Triggers', adData.triggers);
+
+ adData.newAdDataItems.forEach((item, index) => {
+ formData.append(`NewAdDataItems[${index}].storeId`, item.storeId);
+ formData.append(`NewAdDataItems[${index}].productId`, item.productId);
+ formData.append(
+ `NewAdDataItems[${index}].description`,
+ item.description || ''
+ );
+ if (item.imageFile instanceof File) {
+ formData.append(
+ `NewAdDataItems[${index}].imageFile`,
+ item.imageFile,
+ item.imageFile.name
+ );
+ }
+ });
+
+ const response = await axios.put(
+ `${baseApiUrl}/api/AdminAnalytics/advertisements/${advertisementId}`,
+ formData,
+ {
+ headers: { 'Content-Type': 'multipart/form-data' },
+ }
+ );
+
+ return { status: response.status, data: response.data };
+ } catch (error) {
+ console.error('Error updating advertisement:', error);
+ return { status: error.response?.status || 500, data: null };
+ }
+};
+
+export const apiRemoveAdItemAsync = async (id) => {
+ apiSetAuthHeader();
+ return axios.delete(`${baseApiUrl}/api/AdminAnalytics/data/${id}`);
+};
+
+export const apiGetRegionsAsync = async () => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(`${baseApiUrl}/api/Geography/regions`);
+ return res.data; // [{ id, name, countryCode }]
+ } catch (error) {
+ console.error('Error fetching regions:', error);
+ return [];
+ }
+};
+
+export const apiGetGeographyAsync = async () => {
+ apiSetAuthHeader();
+ try {
+ const response = await axios.get(`${baseApiUrl}/api/Geography/geography`);
+ return {
+ regions: response.data.regions || [],
+ places: response.data.places || [],
+ };
+ } catch (error) {
+ console.error('Error fetching geography data:', error);
+ return { regions: [], places: [] };
+ }
+};
+
+export const apiFetchAdClicksAsync = async (id) => {
+ const token = localStorage.getItem('token');
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+ return axios.get(
+ `${baseApiUrl}/api/AdminAnalytics/advertisement/${id}/clicks`
+ );
+};
+
+export const apiFetchAdViewsAsync = async (id) => {
+ const token = localStorage.getItem('token');
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+ return axios.get(
+ `${baseApiUrl}/api/AdminAnalytics/advertisement/${id}/views`
+ );
+};
+
+export const apiFetchAdConversionsAsync = async (id) => {
+ const token = localStorage.getItem('token');
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+ return axios.get(
+ `${baseApiUrl}/api/AdminAnalytics/advertisement/${id}/conversions`
+ );
+};
+
+export const apiFetchAdsWithProfitAsync = async () => {
+ try {
+ const token = localStorage.getItem('token');
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+
+ const response = await axios.get(
+ `${baseApiUrl}/api/AdminAnalytics/advertisements`
+ );
+ const ads = response.data;
+
+ const allProductIds = []; // Za skupljanje svih productId vrednosti
+
+ const adsWithProfit = ads.map((ad) => {
+ const profit =
+ ad.clicks * ad.clickPrice +
+ ad.views * ad.viewPrice +
+ ad.conversions * ad.conversionPrice;
+
+ // Izdvajanje productId-ova iz adData
+ const adData = ad.adData ?? [];
+ const productIds = adData
+ .filter(
+ (item) => item.productId !== null && item.productId !== undefined
+ )
+ .map((item) => item.productId);
+
+ // Ispis pojedinačnih productId-ova za svaki oglas
+ console.log(`📦 Ad #${ad.id} - productId-ovi:`, productIds);
+
+ allProductIds.push(...productIds); // Dodaj u globalni niz
+
+ const fullAd = {
+ id: ad.id,
+ sellerId: ad.sellerId,
+ views: ad.views,
+ viewPrice: ad.viewPrice,
+ clicks: ad.clicks,
+ clickPrice: ad.clickPrice,
+ conversions: ad.conversions,
+ conversionPrice: ad.conversionPrice,
+ startTime: ad.startTime,
+ endTime: ad.endTime,
+ isActive: ad.isActive,
+ adType: ad.adType,
+ productCategoryId: ad.productCategoryId ?? null,
+ triggers: ad.triggers,
+ adData: adData,
+ profit: parseFloat(profit.toFixed(2)),
+ };
+
+ return fullAd;
+ });
+
+ // Uklanjanje duplikata
+ const uniqueProductIds = [...new Set(allProductIds)];
+
+ // Sačuvaj u localStorage
+ localStorage.setItem('adProductIds', JSON.stringify(uniqueProductIds));
+
+ // Ispis svih sačuvanih ID-eva
+ console.log(
+ '✅ Svi sačuvani productId-ovi u localStorage:',
+ uniqueProductIds
+ );
+
+ return adsWithProfit;
+ } catch (error) {
+ console.error('❌ Greška pri dohvaćanju oglasa:', error);
+ return [];
+ }
+};
+
+export const apiFetchProductsByIdsAsync = async () => {
+ try {
+ const token = localStorage.getItem('token');
+ if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+ }
+
+ const storedProductIds = JSON.parse(localStorage.getItem('adProductIds'));
+
+ if (
+ !storedProductIds ||
+ !Array.isArray(storedProductIds) ||
+ storedProductIds.length === 0
+ ) {
+ console.warn('⚠️ Nema productId vrednosti u localStorage.');
+ return [];
+ }
+
+ console.log('📦 Product ID-ovi koji će biti dohvaćeni:', storedProductIds);
+
+ const productRequests = storedProductIds.map(async (productId) => {
+ try {
+ const response = await axios.get(
+ `${baseApiUrl}/api/Admin/products/${productId}`
+ );
+ console.log(`✅ Proizvod ${productId} uspešno dohvaćen.`);
+ return response.data;
+ } catch (err) {
+ console.error(`❌ Greška pri dohvaćanju proizvoda ${productId}:`, err);
+ return null;
+ }
+ });
+
+ const allProducts = await Promise.all(productRequests);
+
+ // Filtriraj neuspešne (null) odgovore
+ const validProducts = allProducts.filter((p) => p !== null);
+
+ console.log(
+ '✅ Ukupno uspešno dohvaćenih proizvoda:',
+ validProducts.length
+ );
+ return validProducts;
+ } catch (error) {
+ console.error('❌ Globalna greška pri dohvaćanju proizvoda:', error);
+ return [];
+ }
+};
+
+//rute
+export const createRouteAsync = async (orders, directionsResponse) => {
+ const rawData = JSON.stringify(directionsResponse);
+ const hash = sha256(rawData).toString();
+
+ const payload = {
+ orderIds: orders.map((o) => o.id),
+ routeData: {
+ data: rawData,
+ hash: hash,
+ },
+ };
+
+ const response = await axios.post(
+ `${baseApiUrl}/api/Delivery/routes`,
+ payload
+ );
+ return response.data;
+};
+
+export const apiFetchAllTicketsAsync = async ({
+ status = '',
+ pageNumber = 1,
+ pageSize = 20,
+} = {}) => {
+ apiSetAuthHeader();
+ try {
+ const params = [];
+ if (status) params.push(`status=${encodeURIComponent(status)}`);
+ if (pageNumber) params.push(`pageNumber=${pageNumber}`);
+ if (pageSize) params.push(`pageSize=${pageSize}`);
+ const query = params.length ? `?${params.join('&')}` : '';
+ const res = await axios.get(`${baseApiUrl}/api/Tickets/all${query}`);
+ return { status: res.status, data: res.data };
+ } catch (err) {
+ console.error('Error fetching tickets:', err);
+ return { status: err.response?.status || 500, data: [] };
+ }
+};
+
+export const apiUpdateTicketStatusAsync = async (ticketId, newStatus) => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.put(
+ `${baseApiUrl}/api/Tickets/${ticketId}/status`,
+ { newStatus }
+ );
+ return { status: res.status, data: res.data };
+ } catch (err) {
+ console.error('Error updating ticket status:', err);
+ return { status: err.response?.status || 500, data: null };
+ }
+};
+
+export const apiFetchAllConversationsAsync = async () => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(`${baseApiUrl}/api/Chat/conversations`);
+ return { status: res.status, data: res.data };
+ } catch (err) {
+ console.error('Error fetching conversations:', err);
+ return { status: err.response?.status || 500, data: [] };
+ }
+};
+
+export const apiFetchMessagesForConversationAsync = async (
+ conversationId,
+ page = 1,
+ pageSize = 30
+) => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(
+ `${baseApiUrl}/api/Chat/conversations/${conversationId}/messages?page=${page}&pageSize=${pageSize}`
+ );
+ return { status: res.status, data: res.data };
+ } catch (err) {
+ console.error('Error fetching messages:', err);
+ return { status: err.response?.status || 500, data: [] };
+ }
+};
+
+export const apiDeleteTicketAsync = async (ticketId) => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.delete(`${baseApiUrl}/api/Tickets/${ticketId}`);
+ return { status: res.status };
+ } catch (err) {
+ console.error('Error deleting ticket:', err);
+ return { status: err.response?.status || 500 };
+ }
+};
+
+export const fetchAdressesAsync = async () => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(`${baseApiUrl}/api/user-profile/address`);
+ return res.data;
+ } catch (err) {
+ console.error('Error finding address.', err);
+ return { status: err.response?.status || 500 };
+ }
+};
+
+export const fetchAdressByIdAsync = async (id) => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(`${baseApiUrl}/api/user-profile/address/${id}`);
+ return res.data;
+ } catch (err) {
+ console.error('Error finding address.', err);
+ return { status: err.response?.status || 500 };
+ }
+};
+
+export const apiGetRoutesAsync = async () => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.get(`${baseApiUrl}/api/Delivery/routes`);
+ return res.data;
+ } catch (err) {
+ console.error('Error getting routes.', err);
+ return { status: err.response?.status || 500 };
+ }
+};
+
+export const apiDeleteRouteAsync = async (id) => {
+ apiSetAuthHeader();
+ try {
+ const res = await axios.delete(`${baseApiUrl}/api/Delivery/routes/${id}`);
+ return res.status;
+ } catch (err) {
+ console.error('Error getting routes.', err);
+ return { status: err.response?.status || 500 };
+ }
+};
+
+export const getGoogle = async (origin, destination, waypoints) => {
+ const apiKey = 'AIzaSyAiW6HWTmBB84hHGcxxUdPHwRcc6vpbPRo';
+
+ const url = `https://maps.googleapis.com/maps/api/directions/json?origin=${encodeURIComponent(
+ origin
+ )}&destination=${encodeURIComponent(
+ destination
+ )}&waypoints=${encodeURIComponent(waypoints)}&key=${apiKey}`;
+
+ try {
+ const response = await axios.get(url);
+ const directionsJson = response.data; // Axios automatically parses the JSON
+
+ if (directionsJson.status !== 'OK') {
+ alert('Google Maps API Error: ' + directionsJson.status);
+ return null;
+ }
+
+ return directionsJson;
+ } catch (err) {
+ console.error('Error fetching directions:', err);
+ alert('An error occurred while fetching directions.');
+ return null;
+ }
+};
+
+/**
+ * Fetches the optimal route from Google Directions API.
+ * @async
+ * @param {string[]} locs - Array of location strings (addresses or lat/lng).
+ * @param {string} transportMode - 'driving' or 'walking'.
+ * @returns {Promise} The first route object from the API response or null.
+ */
+export const apiExternGetOptimalRouteAsync = async (locs, transportMode) => {
+ if (!locs) return null;
+ if (locs.length < 2) {
+ console.warn('Need at least an origin and destination for a route.');
+ return null;
+ }
+ try {
+ const origin = locs[0];
+ const destination = locs[locs.length - 1];
+ // const waypointsParam = locs.slice(1, -1).map(loc => ({ location: loc, stopover: true }));
+
+ const waypointsString = locs
+ .slice(1, -1)
+ .map((loc) => encodeURIComponent(loc))
+ .join('|');
+ if (!origin || !destination) return;
+ const url = `https://maps.googleapis.com/maps/api/directions/json?origin=${encodeURIComponent(origin)}&destination=${encodeURIComponent(destination)}&waypoints=optimize:true|${waypointsString}&mode=${transportMode}&key=${googleMapsApiKey}`;
+ const netlifyFunctionEndpoint = `/api/netlify/directions`;
+ console.log('Requesting directions URL:', url);
+
+ // const response = await axios.get(netlifyFunctionEndpoint, {
+ // params: {
+ // url: url, // Pass the partial URL
+ // },
+ // });
+ const response = await fetch(url, { method: 'GET', mode: 'no-cors' });
+
+ if (!response.ok)
+ throw new Error(`Error fetching route: ${response.statusText}`);
+
+ const data = await response.json();
+ if (data.status !== 'OK')
+ throw new Error(
+ `API error: ${data.status} - ${data.error_message || 'Unknown error'}`
+ );
+
+ //setOptimalRouteData(data.routes[0]);
+ //await saveDataToLocalStorage(data, `route-${transportMode}.json`);
+ console.log(data.routes[0]);
+ return data.routes[0];
+ } catch (error) {
+ console.error('Error fetching optimal route:', error);
+ //window.alert(t('Error fetching route. Please check console.'));
+ return null;
+ }
+};
+
+export const apiCreateRouteAsync = async (orders) => {
+ if (orders.length == 0) return;
+ apiSetAuthHeader();
+ const payload = {
+ orderIds: orders.map((o) => o.id),
+ };
+ const response = await axios.post(
+ `${baseApiUrl}/api/Delivery/routes/create`,
+ JSON.stringify(payload),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ }
+ );
+ return response.data;
+};
+
+export const apiGetOrderAddresses = async (orders) => {
+ const addresses = [];
+ for (let index = 0; index < orders.length; index++) {
+ const addr = await fetchAdressByIdAsync(orders[index].addressId);
+ if (orders[index].storeId) {
+ const store = await apiGetStoreByIdAsync(orders[index].storeId);
+ addresses.push(store.address);
+ }
+ addresses.push(addr.address);
+ }
+ return addresses;
+};
+
+export const apiGetAllRoutesAsync = async () => {
+ apiSetAuthHeader();
+ return axios.get(`${baseApiUrl}/api/Delivery/routes`);
+};
+
+
+
+export const apiGetStoreIncomeAsync = async (storeId, from, to) => {
+ try {
+ const response = await axios.get(
+ `${baseApiUrl}/api/Admin/store/${storeId}/income`,
+ {
+ params: { from, to }
+ }
+ );
+ return response.data;
+ } catch (error) {
+ console.error(`❌ Error fetching store income for ID ${storeId}:`, error);
+ throw error;
+ }
+};
+
+
+
+
+export const apiFetchDeliveryAddressByIdAsync = async (addressId) => {
+ const res = await axios.get(
+ `${baseApiUrl}/api/user-profile/address/${addressId}`
+ );
+ return res.data; // Vraća objekat adrese
+};
+
diff --git a/src/api/api/AdminApi.js b/src/api/api/AdminApi.js
new file mode 100644
index 0000000..8355021
--- /dev/null
+++ b/src/api/api/AdminApi.js
@@ -0,0 +1,210 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from "../ApiClient";
+import ApproveUserDto from '../model/ApproveUserDto';
+import CreateUserDto from '../model/CreateUserDto';
+import ProblemDetails from '../model/ProblemDetails';
+import UserInfoDto from '../model/UserInfoDto';
+
+/**
+* Admin service.
+* @module api/AdminApi
+* @version v1
+*/
+export default class AdminApi {
+
+ /**
+ * Constructs a new AdminApi.
+ * @alias module:api/AdminApi
+ * @class
+ * @param {module:ApiClient} [apiClient] Optional API client implementation to use,
+ * default to {@link module:ApiClient#instanc
+ e} if unspecified.
+ */
+ constructor(apiClient) {
+ this.apiClient = apiClient || ApiClient.instance;
+ }
+
+ /**
+ * Callback function to receive the result of the apiAdminUserIdDelete operation.
+ * @callback moduleapi/AdminApi~apiAdminUserIdDeleteCallback
+ * @param {String} error Error message, if any.
+ * @param {'String'{ data The data returned by the service call.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * @param {String} id
+ * @param {module:api/AdminApi~apiAdminUserIdDeleteCallback} callback The callback function, accepting three arguments: error, data, response
+ * data is of type: {@link <&vendorExtensions.x-jsdoc-type>}
+ */
+ apiAdminUserIdDelete(id, callback) {
+
+ let postBody = null;
+ // verify the required parameter 'id' is set
+ if (id === undefined || id === null) {
+ throw new Error("Missing the required parameter 'id' when calling apiAdminUserIdDelete");
+ }
+
+ let pathParams = {
+ 'id': id
+ };
+ let queryParams = {
+
+ };
+ let headerParams = {
+
+ };
+ let formParams = {
+
+ };
+
+ let authNames = [];
+ let contentTypes = [];
+ let accepts = ['text/plain', 'application/json', 'text/json'];
+ let returnType = 'String';
+
+ return this.apiClient.callApi(
+ '/api/Admin/user/{id}', 'DELETE',
+ pathParams, queryParams, headerParams, formParams, postBody,
+ authNames, contentTypes, accepts, returnType, callback
+ );
+ }
+ /**
+ * Callback function to receive the result of the apiAdminUsersApprovePost operation.
+ * @callback moduleapi/AdminApi~apiAdminUsersApprovePostCallback
+ * @param {String} error Error message, if any.
+ * @param {'String'{ data The data returned by the service call.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * @param {Object} opts Optional parameters
+ * @param {module:model/ApproveUserDto} opts.body
+ * @param {module:api/AdminApi~apiAdminUsersApprovePostCallback} callback The callback function, accepting three arguments: error, data, response
+ * data is of type: {@link <&vendorExtensions.x-jsdoc-type>}
+ */
+ apiAdminUsersApprovePost(opts, callback) {
+ opts = opts || {};
+ let postBody = opts['body'];
+
+ let pathParams = {
+
+ };
+ let queryParams = {
+
+ };
+ let headerParams = {
+
+ };
+ let formParams = {
+
+ };
+
+ let authNames = [];
+ let contentTypes = ['application/json', 'text/json', 'application/_*+json'];
+ let accepts = ['text/plain', 'application/json', 'text/json'];
+ let returnType = 'String';
+
+ return this.apiClient.callApi(
+ '/api/Admin/users/approve', 'POST',
+ pathParams, queryParams, headerParams, formParams, postBody,
+ authNames, contentTypes, accepts, returnType, callback
+ );
+ }
+ /**
+ * Callback function to receive the result of the apiAdminUsersCreatePost operation.
+ * @callback moduleapi/AdminApi~apiAdminUsersCreatePostCallback
+ * @param {String} error Error message, if any.
+ * @param {module:model/UserInfoDto{ data The data returned by the service call.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * @param {Object} opts Optional parameters
+ * @param {module:model/CreateUserDto} opts.body
+ * @param {module:api/AdminApi~apiAdminUsersCreatePostCallback} callback The callback function, accepting three arguments: error, data, response
+ * data is of type: {@link <&vendorExtensions.x-jsdoc-type>}
+ */
+ apiAdminUsersCreatePost(opts, callback) {
+ opts = opts || {};
+ let postBody = opts['body'];
+
+ let pathParams = {
+
+ };
+ let queryParams = {
+
+ };
+ let headerParams = {
+
+ };
+ let formParams = {
+
+ };
+
+ let authNames = [];
+ let contentTypes = ['application/json', 'text/json', 'application/_*+json'];
+ let accepts = ['text/plain', 'application/json', 'text/json'];
+ let returnType = UserInfoDto;
+
+ return this.apiClient.callApi(
+ '/api/Admin/users/create', 'POST',
+ pathParams, queryParams, headerParams, formParams, postBody,
+ authNames, contentTypes, accepts, returnType, callback
+ );
+ }
+ /**
+ * Callback function to receive the result of the apiAdminUsersGet operation.
+ * @callback moduleapi/AdminApi~apiAdminUsersGetCallback
+ * @param {String} error Error message, if any.
+ * @param {Array.{ data The data returned by the service call.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * @param {module:api/AdminApi~apiAdminUsersGetCallback} callback The callback function, accepting three arguments: error, data, response
+ * data is of type: {@link <&vendorExtensions.x-jsdoc-type>}
+ */
+ apiAdminUsersGet(callback) {
+
+ let postBody = null;
+
+ let pathParams = {
+
+ };
+ let queryParams = {
+
+ };
+ let headerParams = {
+
+ };
+ let formParams = {
+
+ };
+
+ let authNames = [];
+ let contentTypes = [];
+ let accepts = ['text/plain', 'application/json', 'text/json'];
+ let returnType = [UserInfoDto];
+
+ return this.apiClient.callApi(
+ '/api/Admin/users', 'GET',
+ pathParams, queryParams, headerParams, formParams, postBody,
+ authNames, contentTypes, accepts, returnType, callback
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/src/api/api/FacebookAuthApi.js b/src/api/api/FacebookAuthApi.js
new file mode 100644
index 0000000..4242818
--- /dev/null
+++ b/src/api/api/FacebookAuthApi.js
@@ -0,0 +1,78 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from "../ApiClient";
+
+/**
+* FacebookAuth service.
+* @module api/FacebookAuthApi
+* @version v1
+*/
+export default class FacebookAuthApi {
+
+ /**
+ * Constructs a new FacebookAuthApi.
+ * @alias module:api/FacebookAuthApi
+ * @class
+ * @param {module:ApiClient} [apiClient] Optional API client implementation to use,
+ * default to {@link module:ApiClient#instanc
+ e} if unspecified.
+ */
+ constructor(apiClient) {
+ this.apiClient = apiClient || ApiClient.instance;
+ }
+
+ /**
+ * Callback function to receive the result of the apiFacebookAuthLoginPost operation.
+ * @callback moduleapi/FacebookAuthApi~apiFacebookAuthLoginPostCallback
+ * @param {String} error Error message, if any.
+ * @param data This operation does not return a value.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * @param {Object} opts Optional parameters
+ * @param {String} opts.body
+ * @param {module:api/FacebookAuthApi~apiFacebookAuthLoginPostCallback} callback The callback function, accepting three arguments: error, data, response
+ */
+ apiFacebookAuthLoginPost(opts, callback) {
+ opts = opts || {};
+ let postBody = opts['body'];
+
+ let pathParams = {
+
+ };
+ let queryParams = {
+
+ };
+ let headerParams = {
+
+ };
+ let formParams = {
+
+ };
+
+ let authNames = [];
+ let contentTypes = ['application/json', 'text/json', 'application/_*+json'];
+ let accepts = [];
+ let returnType = null;
+
+ return this.apiClient.callApi(
+ '/api/FacebookAuth/login', 'POST',
+ pathParams, queryParams, headerParams, formParams, postBody,
+ authNames, contentTypes, accepts, returnType, callback
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/src/api/api/TestAuthApi.js b/src/api/api/TestAuthApi.js
new file mode 100644
index 0000000..ebec88a
--- /dev/null
+++ b/src/api/api/TestAuthApi.js
@@ -0,0 +1,122 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from "../ApiClient";
+import LoginDTO from '../model/LoginDTO';
+
+/**
+* TestAuth service.
+* @module api/TestAuthApi
+* @version v1
+*/
+export default class TestAuthApi {
+
+ /**
+ * Constructs a new TestAuthApi.
+ * @alias module:api/TestAuthApi
+ * @class
+ * @param {module:ApiClient} [apiClient] Optional API client implementation to use,
+ * default to {@link module:ApiClient#instanc
+ e} if unspecified.
+ */
+ constructor(apiClient) {
+ this.apiClient = apiClient || ApiClient.instance;
+ }
+
+ /**
+ * Callback function to receive the result of the apiTestAuthLoginPost operation.
+ * @callback moduleapi/TestAuthApi~apiTestAuthLoginPostCallback
+ * @param {String} error Error message, if any.
+ * @param {'String'{ data The data returned by the service call.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * @param {Object} opts Optional parameters
+ * @param {module:model/LoginDTO} opts.body
+ * @param {module:api/TestAuthApi~apiTestAuthLoginPostCallback} callback The callback function, accepting three arguments: error, data, response
+ * data is of type: {@link <&vendorExtensions.x-jsdoc-type>}
+ */
+ apiTestAuthLoginPost(opts, callback) {
+ opts = opts || {};
+ let postBody = opts;
+
+ let pathParams = {
+
+ };
+ let queryParams = {
+
+ };
+ let headerParams = {
+
+ };
+ let formParams = {
+
+ };
+
+ let authNames = [];
+ let contentTypes = ['application/json', 'text/json', 'application/_*+json'];
+ let accepts = ['text/plain', 'application/json', 'text/json'];
+ let returnType = 'String';
+
+ console.log(opts);
+
+ return this.apiClient.callApi(
+ '/api/TestAuth/login', 'POST',
+ pathParams, queryParams, headerParams, formParams, postBody,
+ authNames, contentTypes, accepts, returnType, callback
+ );
+ }
+ /**
+ * Callback function to receive the result of the apiTestAuthRegisterPost operation.
+ * @callback moduleapi/TestAuthApi~apiTestAuthRegisterPostCallback
+ * @param {String} error Error message, if any.
+ * @param {'String'{ data The data returned by the service call.
+ * @param {String} response The complete HTTP response.
+ */
+
+ /**
+ * @param {module:api/TestAuthApi~apiTestAuthRegisterPostCallback} callback The callback function, accepting three arguments: error, data, response
+ * data is of type: {@link <&vendorExtensions.x-jsdoc-type>}
+ */
+ apiTestAuthRegisterPost(callback) {
+
+ let postBody = null;
+
+ let pathParams = {
+
+ };
+ let queryParams = {
+
+ };
+ let headerParams = {
+
+ };
+ let formParams = {
+
+ };
+
+ let authNames = [];
+ let contentTypes = [];
+ let accepts = ['text/plain', 'application/json', 'text/json'];
+ let returnType = 'String';
+
+ return this.apiClient.callApi(
+ '/api/Auth/register', 'POST',
+ pathParams, queryParams, headerParams, formParams, postBody,
+ authNames, contentTypes, accepts, returnType, callback
+ );
+ }
+
+}
\ No newline at end of file
diff --git a/src/api/apiClientInstance.js b/src/api/apiClientInstance.js
new file mode 100644
index 0000000..ba3e137
--- /dev/null
+++ b/src/api/apiClientInstance.js
@@ -0,0 +1,29 @@
+// src/api/apiClientInstance.js
+import ApiClient from './ApiClient'; // Import the generated ApiClient
+import request from 'superagent'; // Import superagent directly TO CONFIGURE DEFAULTS
+
+const apiClientInstance = new ApiClient();
+
+// Configure the base path (use HTTP as per your HAR log)
+apiClientInstance.basePath = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5054';
+console.log(`ApiClient basePath configured to: ${apiClientInstance.basePath}`);
+
+
+// --- Configure superagent defaults to SEND COOKIES ---
+// This tells *all* subsequent superagent requests made through its standard import
+// (which the generated ApiClient likely uses) to include credentials (cookies).
+try {
+ // THIS IS THE KEY LINE FOR COOKIE AUTH:
+ request.defaults({ withCredentials: true });
+ console.log("Superagent default withCredentials configured globally.");
+} catch (err) {
+ // This shouldn't normally fail, but good to have a catch block
+ console.error("Could not configure superagent defaults:", err);
+}
+// --- End of superagent configuration ---
+
+
+// No need to override callApi or look for ApiClient specific options,
+// as applyAuthToRequest only handles spec-defined auth schemes, not cookies.
+
+export default apiClientInstance;
\ No newline at end of file
diff --git a/src/api/index.js b/src/api/index.js
new file mode 100644
index 0000000..9b93e73
--- /dev/null
+++ b/src/api/index.js
@@ -0,0 +1,110 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from './ApiClient';
+import ApproveUserDto from './model/ApproveUserDto';
+import CreateUserDto from './model/CreateUserDto';
+import LoginDTO from './model/LoginDTO';
+import ProblemDetails from './model/ProblemDetails';
+import UserInfoDto from './model/UserInfoDto';
+import AdminApi from './api/AdminApi';
+import FacebookAuthApi from './api/FacebookAuthApi';
+import TestAuthApi from './api/TestAuthApi';
+
+/**
+* Object.
+* The index module provides access to constructors for all the classes which comprise the public API.
+*
+* An AMD (recommended!) or CommonJS application will generally do something equivalent to the following:
+*
+* var BazaarApi = require('index'); // See note below*.
+* var xxxSvc = new BazaarApi.XxxApi(); // Allocate the API class we're going to use.
+* var yyyModel = new BazaarApi.Yyy(); // Construct a model instance.
+* yyyModel.someProperty = 'someValue';
+* ...
+* var zzz = xxxSvc.doSomething(yyyModel); // Invoke the service.
+* ...
+*
+* *NOTE: For a top-level AMD script, use require(['index'], function(){...})
+* and put the application logic within the callback function.
+*
+*
+* A non-AMD browser application (discouraged) might do something like this:
+*
+* var xxxSvc = new BazaarApi.XxxApi(); // Allocate the API class we're going to use.
+* var yyy = new BazaarApi.Yyy(); // Construct a model instance.
+* yyyModel.someProperty = 'someValue';
+* ...
+* var zzz = xxxSvc.doSomething(yyyModel); // Invoke the service.
+* ...
+*
+*
+* @module index
+* @version v1
+*/
+export {
+ /**
+ * The ApiClient constructor.
+ * @property {module:ApiClient}
+ */
+ ApiClient,
+
+ /**
+ * The ApproveUserDto model constructor.
+ * @property {module:model/ApproveUserDto}
+ */
+ ApproveUserDto,
+
+ /**
+ * The CreateUserDto model constructor.
+ * @property {module:model/CreateUserDto}
+ */
+ CreateUserDto,
+
+ /**
+ * The LoginDTO model constructor.
+ * @property {module:model/LoginDTO}
+ */
+ LoginDTO,
+
+ /**
+ * The ProblemDetails model constructor.
+ * @property {module:model/ProblemDetails}
+ */
+ ProblemDetails,
+
+ /**
+ * The UserInfoDto model constructor.
+ * @property {module:model/UserInfoDto}
+ */
+ UserInfoDto,
+
+ /**
+ * The AdminApi service constructor.
+ * @property {module:api/AdminApi}
+ */
+ AdminApi,
+
+ /**
+ * The FacebookAuthApi service constructor.
+ * @property {module:api/FacebookAuthApi}
+ */
+ FacebookAuthApi,
+
+ /**
+ * The TestAuthApi service constructor.
+ * @property {module:api/TestAuthApi}
+ */
+ TestAuthApi
+};
diff --git a/src/api/model/ApproveUserDto.js b/src/api/model/ApproveUserDto.js
new file mode 100644
index 0000000..02f76bb
--- /dev/null
+++ b/src/api/model/ApproveUserDto.js
@@ -0,0 +1,54 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from '../ApiClient';
+
+/**
+ * The ApproveUserDto model module.
+ * @module model/ApproveUserDto
+ * @version v1
+ */
+export default class ApproveUserDto {
+ /**
+ * Constructs a new ApproveUserDto.
+ * @alias module:model/ApproveUserDto
+ * @class
+ * @param userId {String}
+ */
+ constructor(userId) {
+ this.userId = userId;
+ }
+
+ /**
+ * Constructs a ApproveUserDto from a plain JavaScript object, optionally creating a new instance.
+ * Copies all relevant properties from data to obj if supplied or a new instance if not.
+ * @param {Object} data The plain JavaScript object bearing properties of interest.
+ * @param {module:model/ApproveUserDto} obj Optional instance to populate.
+ * @return {module:model/ApproveUserDto} The populated ApproveUserDto instance.
+ */
+ static constructFromObject(data, obj) {
+ if (data) {
+ obj = obj || new ApproveUserDto();
+ if (data.hasOwnProperty('userId'))
+ obj.userId = ApiClient.convertToType(data['userId'], 'String');
+ }
+ return obj;
+ }
+}
+
+/**
+ * @member {String} userId
+ */
+ApproveUserDto.prototype.userId = undefined;
+
diff --git a/src/api/model/CreateUserDto.js b/src/api/model/CreateUserDto.js
new file mode 100644
index 0000000..4344a20
--- /dev/null
+++ b/src/api/model/CreateUserDto.js
@@ -0,0 +1,72 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from '../ApiClient';
+
+/**
+ * The CreateUserDto model module.
+ * @module model/CreateUserDto
+ * @version v1
+ */
+export default class CreateUserDto {
+ /**
+ * Constructs a new CreateUserDto.
+ * @alias module:model/CreateUserDto
+ * @class
+ * @param userName {String}
+ * @param email {String}
+ * @param password {String}
+ */
+ constructor(userName, email, password) {
+ this.userName = userName;
+ this.email = email;
+ this.password = password;
+ }
+
+ /**
+ * Constructs a CreateUserDto from a plain JavaScript object, optionally creating a new instance.
+ * Copies all relevant properties from data to obj if supplied or a new instance if not.
+ * @param {Object} data The plain JavaScript object bearing properties of interest.
+ * @param {module:model/CreateUserDto} obj Optional instance to populate.
+ * @return {module:model/CreateUserDto} The populated CreateUserDto instance.
+ */
+ static constructFromObject(data, obj) {
+ if (data) {
+ obj = obj || new CreateUserDto();
+ if (data.hasOwnProperty('userName'))
+ obj.userName = ApiClient.convertToType(data['userName'], 'String');
+ if (data.hasOwnProperty('email'))
+ obj.email = ApiClient.convertToType(data['email'], 'String');
+ if (data.hasOwnProperty('password'))
+ obj.password = ApiClient.convertToType(data['password'], 'String');
+ }
+ return obj;
+ }
+}
+
+/**
+ * @member {String} userName
+ */
+CreateUserDto.prototype.userName = undefined;
+
+/**
+ * @member {String} email
+ */
+CreateUserDto.prototype.email = undefined;
+
+/**
+ * @member {String} password
+ */
+CreateUserDto.prototype.password = undefined;
+
diff --git a/src/api/model/LoginDTO.js b/src/api/model/LoginDTO.js
new file mode 100644
index 0000000..d79a3c8
--- /dev/null
+++ b/src/api/model/LoginDTO.js
@@ -0,0 +1,63 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from '../ApiClient';
+
+/**
+ * The LoginDTO model module.
+ * @module model/LoginDTO
+ * @version v1
+ */
+export default class LoginDTO {
+ /**
+ * Constructs a new LoginDTO.
+ * @alias module:model/LoginDTO
+ * @class
+ * @param username {String}
+ * @param password {String}
+ */
+ constructor(username, password) {
+ this.username = username;
+ this.password = password;
+ }
+
+ /**
+ * Constructs a LoginDTO from a plain JavaScript object, optionally creating a new instance.
+ * Copies all relevant properties from data to obj if supplied or a new instance if not.
+ * @param {Object} data The plain JavaScript object bearing properties of interest.
+ * @param {module:model/LoginDTO} obj Optional instance to populate.
+ * @return {module:model/LoginDTO} The populated LoginDTO instance.
+ */
+ static constructFromObject(data, obj) {
+ if (data) {
+ obj = obj || new LoginDTO();
+ if (data.hasOwnProperty('username'))
+ obj.username = ApiClient.convertToType(data['username'], 'String');
+ if (data.hasOwnProperty('password'))
+ obj.password = ApiClient.convertToType(data['password'], 'String');
+ }
+ return obj;
+ }
+}
+
+/**
+ * @member {String} username
+ */
+LoginDTO.prototype.username = undefined;
+
+/**
+ * @member {String} password
+ */
+LoginDTO.prototype.password = undefined;
+
diff --git a/src/api/model/ProblemDetails.js b/src/api/model/ProblemDetails.js
new file mode 100644
index 0000000..be57235
--- /dev/null
+++ b/src/api/model/ProblemDetails.js
@@ -0,0 +1,46 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from '../ApiClient';
+
+/**
+ * The ProblemDetails model module.
+ * @module model/ProblemDetails
+ * @version v1
+ */
+export default class ProblemDetails {
+ /**
+ * Constructs a new ProblemDetails.
+ * @alias module:model/ProblemDetails
+ * @class
+ * @extends Object
+ */
+ constructor() {
+ }
+
+ /**
+ * Constructs a ProblemDetails from a plain JavaScript object, optionally creating a new instance.
+ * Copies all relevant properties from data to obj if supplied or a new instance if not.
+ * @param {Object} data The plain JavaScript object bearing properties of interest.
+ * @param {module:model/ProblemDetails} obj Optional instance to populate.
+ * @return {module:model/ProblemDetails} The populated ProblemDetails instance.
+ */
+ static constructFromObject(data, obj) {
+ if (data) {
+ obj = obj || new ProblemDetails();
+ ApiClient.constructFromObject(data, obj, 'Object');
+ }
+ return obj;
+ }
+}
diff --git a/src/api/model/UserInfoDto.js b/src/api/model/UserInfoDto.js
new file mode 100644
index 0000000..6349074
--- /dev/null
+++ b/src/api/model/UserInfoDto.js
@@ -0,0 +1,87 @@
+/*
+ * Bazaar API
+ * No description provided (generated by Swagger Codegen https://github.com/swagger-api/swagger-codegen)
+ *
+ * OpenAPI spec version: v1
+ *
+ * NOTE: This class is auto generated by the swagger code generator program.
+ * https://github.com/swagger-api/swagger-codegen.git
+ *
+ * Swagger Codegen version: 3.0.68
+ *
+ * Do not edit the class manually.
+ *
+ */
+import ApiClient from '../ApiClient';
+
+/**
+ * The UserInfoDto model module.
+ * @module model/UserInfoDto
+ * @version v1
+ */
+export default class UserInfoDto {
+ /**
+ * Constructs a new UserInfoDto.
+ * @alias module:model/UserInfoDto
+ * @class
+ */
+ constructor() {
+ }
+
+ /**
+ * Constructs a UserInfoDto from a plain JavaScript object, optionally creating a new instance.
+ * Copies all relevant properties from data to obj if supplied or a new instance if not.
+ * @param {Object} data The plain JavaScript object bearing properties of interest.
+ * @param {module:model/UserInfoDto} obj Optional instance to populate.
+ * @return {module:model/UserInfoDto} The populated UserInfoDto instance.
+ */
+ static constructFromObject(data, obj) {
+ if (data) {
+ obj = obj || new UserInfoDto();
+ if (data.hasOwnProperty('id'))
+ obj.id = ApiClient.convertToType(data['id'], 'String');
+ if (data.hasOwnProperty('userName'))
+ obj.userName = ApiClient.convertToType(data['userName'], 'String');
+ if (data.hasOwnProperty('email'))
+ obj.email = ApiClient.convertToType(data['email'], 'String');
+ if (data.hasOwnProperty('emailConfirmed'))
+ obj.emailConfirmed = ApiClient.convertToType(data['emailConfirmed'], 'Boolean');
+ if (data.hasOwnProperty('roles'))
+ obj.roles = ApiClient.convertToType(data['roles'], ['String']);
+ if (data.hasOwnProperty('isApproved'))
+ obj.isApproved = ApiClient.convertToType(data['isApproved'], 'Boolean');
+ }
+ return obj;
+ }
+}
+
+/**
+ * @member {String} id
+ */
+UserInfoDto.prototype.id = undefined;
+
+/**
+ * @member {String} userName
+ */
+UserInfoDto.prototype.userName = undefined;
+
+/**
+ * @member {String} email
+ */
+UserInfoDto.prototype.email = undefined;
+
+/**
+ * @member {Boolean} emailConfirmed
+ */
+UserInfoDto.prototype.emailConfirmed = undefined;
+
+/**
+ * @member {Array.} roles
+ */
+UserInfoDto.prototype.roles = undefined;
+
+/**
+ * @member {Boolean} isApproved
+ */
+UserInfoDto.prototype.isApproved = undefined;
+
diff --git a/src/assets/fonts/.gitkeep b/src/assets/fonts/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/assets/icons/.gitkeep b/src/assets/icons/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/assets/icons/admin.svg b/src/assets/icons/admin.svg
new file mode 100644
index 0000000..ca80650
--- /dev/null
+++ b/src/assets/icons/admin.svg
@@ -0,0 +1,6 @@
+
+
diff --git a/src/assets/images/.gitkeep b/src/assets/images/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/assets/images/Bazaar.png b/src/assets/images/Bazaar.png
new file mode 100644
index 0000000..cad22e6
Binary files /dev/null and b/src/assets/images/Bazaar.png differ
diff --git a/src/assets/images/background.jpg b/src/assets/images/background.jpg
new file mode 100644
index 0000000..8f07497
Binary files /dev/null and b/src/assets/images/background.jpg differ
diff --git a/src/assets/images/bazaarAd.jpg b/src/assets/images/bazaarAd.jpg
new file mode 100644
index 0000000..d265f28
Binary files /dev/null and b/src/assets/images/bazaarAd.jpg differ
diff --git a/src/assets/images/edit-icon.png b/src/assets/images/edit-icon.png
new file mode 100644
index 0000000..7885e39
Binary files /dev/null and b/src/assets/images/edit-icon.png differ
diff --git a/src/assets/images/routing-pointa-ppointb.png b/src/assets/images/routing-pointa-ppointb.png
new file mode 100644
index 0000000..e68e775
Binary files /dev/null and b/src/assets/images/routing-pointa-ppointb.png differ
diff --git a/src/components/.gitkeep b/src/components/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/components/AdCard.jsx b/src/components/AdCard.jsx
new file mode 100644
index 0000000..bef9601
--- /dev/null
+++ b/src/components/AdCard.jsx
@@ -0,0 +1,351 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Box,
+ Paper,
+ Typography,
+ Stack,
+ IconButton,
+ Tooltip,
+} from '@mui/material';
+import {
+ Eye,
+ Hand,
+ Clock,
+ CheckCircle,
+ XCircle,
+ Link as LucideLink,
+ Store,
+ Info,
+ Pencil,
+ Trash2,
+} from 'lucide-react';
+import { toast } from 'react-hot-toast';
+import DeleteConfirmationModal from './DeleteAdConfirmation';
+import EditAdModal from './EditAdModal';
+import { apiFetchApprovedUsersAsync } from '../api/api';
+const baseApiUrl = import.meta.env.VITE_API_BASE_URL;
+import defaultAdImage from '@images/bazaarAd.jpg';
+import { useTranslation } from 'react-i18next';
+
+const IconStat = ({ icon, value, label, bg }) => (
+
+
+ {icon}
+
+
+
+ {value}
+
+
+ {label}
+
+
+
+);
+
+const AdCard = ({ ad, stores, onDelete, onEdit, onViewDetails }) => {
+ const [isDeleteOpen, setIsDeleteOpen] = useState(false);
+ const [isEditOpen, setIsEditOpen] = useState(false);
+ const [sellers, setSellers] = useState([]);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ const fetchUsers = async () => {
+ const rez = await apiFetchApprovedUsersAsync();
+ setSellers(rez);
+ };
+ fetchUsers();
+ }, []);
+
+ const handleDelete = async () => {
+ try {
+ await onDelete(ad.id);
+ toast.success('Ad deleted successfully');
+ } catch (err) {
+ toast.error(err.message || 'Failed to delete ad');
+ } finally {
+ setIsDeleteOpen(false);
+ }
+ };
+
+ const handleEdit = async (adId, payload) => {
+ try {
+ await onEdit(adId, payload);
+ toast.success('Ad updated successfully');
+ } catch (err) {
+ toast.error(err.message || 'Failed to update ad');
+ } finally {
+ setIsEditOpen(false);
+ }
+ };
+
+ const handleDetails = () => {
+ try {
+ onViewDetails(ad.id);
+ } catch (err) {
+ toast.error(err.message || 'Failed to open details');
+ }
+ };
+
+ const adItem = ad.adData[0];
+ const dateRange = `${new Date(ad.startTime).toLocaleDateString()} - ${new Date(ad.endTime).toLocaleDateString()}`;
+
+ return (
+
+
+ {/* Left: Image + Description */}
+
+
+
+
+
+ #{ad.id.toString().padStart(6, '0')} | Seller:{' '}
+ {sellers.find((s) => s.id == ad.sellerId)?.userName ||
+ 'Unknown'}
+
+
+
+ {adItem?.description || 'No Description'}
+
+
+ {adItem?.productId && (
+
+
+
+
+
+ )}
+ {adItem?.storeId && (
+
+
+
+
+
+ )}
+
+
+
+
+ {/* Middle: Stats */}
+
+
+ }
+ value={ad.views}
+ label={t('common.views')}
+ bg='#0284c7'
+ />
+
+
+ }
+ value={ad.clicks}
+ label={t('common.clicks')}
+ bg='#0d9488'
+ />
+
+
+ }
+ value={dateRange}
+ label={t('common.active')}
+ bg='#8b5cf6'
+ />
+
+
+
+
+ {t('common.clickPrice')}:{' '}
+ {ad.clickPrice ?? 'Mock'}
+
+
+ {t('common.viewPrice')}:{' '}
+ {ad.viewPrice ?? 'Mock'}
+
+
+ {t('common.conversionPrice')}:{' '}
+ {ad.conversionPrice ?? 'Mock'}
+
+
+
+
+
+ ) : (
+
+ )
+ }
+ value={ad.isActive ? t('common.active') : t('common.inactive')}
+ label={t('common.status')}
+ bg={ad.isActive ? '#22c55e' : '#f87171'}
+ />
+
+
+
+ {/* Right: Actions */}
+
+
+
+
+
+
+
+ setIsEditOpen(true)}>
+
+
+
+
+ setIsDeleteOpen(true)}
+ >
+
+
+
+
+ {/* ...modals... */}
+
+ setIsEditOpen(false)}
+ onSave={handleEdit}
+ />
+
+ setIsDeleteOpen(false)}
+ onConfirm={handleDelete}
+ />
+
+ );
+};
+
+
+export default AdCard;
diff --git a/src/components/AdContentCard.jsx b/src/components/AdContentCard.jsx
new file mode 100644
index 0000000..5a8c898
--- /dev/null
+++ b/src/components/AdContentCard.jsx
@@ -0,0 +1,88 @@
+import React from 'react';
+import { Box, Typography, Paper, Stack } from '@mui/material';
+import { Store, Package, MessageSquare } from 'lucide-react';
+const baseApiUrl = import.meta.env.VITE_API_BASE_URL;
+
+const AdContentCard = ({ imageUrl, storeName, productName, description }) => {
+ return (
+
+ {/* Left Image */}
+
+
+
+
+ {/* Right Content */}
+
+
+
+
+
+ Store: {storeName}
+
+
+
+
+
+
+ Product: {productName}
+
+
+
+
+
+
+ {description || 'No advertisement text provided.'}
+
+
+
+
+
+ );
+};
+
+export default AdContentCard;
diff --git a/src/components/AdFunnelChart.jsx b/src/components/AdFunnelChart.jsx
new file mode 100644
index 0000000..537f38e
--- /dev/null
+++ b/src/components/AdFunnelChart.jsx
@@ -0,0 +1,217 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { Card, Typography, Box, Grid } from '@mui/material';
+import FunnelCurved from './FunnelCurved';
+import {
+ VisibilityOutlined,
+ MouseOutlined,
+ CheckCircleOutline,
+} from '@mui/icons-material';
+import { apiGetAllAdsAsync } from '../api/api.js';
+import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
+import { useTranslation } from 'react-i18next';
+
+const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
+const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub';
+const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`;
+
+const funnelColors = ['#60a5fa', '#38bdf8', '#0ea5e9'];
+const funnelIcons = [
+ ,
+ ,
+ ,
+];
+
+export default function AdFunnelChart() {
+ const { t } = useTranslation();
+ const [funnelSteps, setFunnelSteps] = useState([
+ {
+ label: t('analytics.viewed'),
+ value: 0,
+ percent: 100,
+ color: funnelColors[0],
+ icon: funnelIcons[0],
+ },
+ {
+ label: t('analytics.clicked'),
+ value: 0,
+ percent: 0,
+ color: funnelColors[1],
+ icon: funnelIcons[1],
+ },
+ {
+ label: t('analytics.converted'),
+ value: 0,
+ percent: 0,
+ color: funnelColors[2],
+ icon: funnelIcons[2],
+ },
+ ]);
+
+ const [ads, setAds] = useState([]);
+ const connectionRef = useRef(null);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const adsResponse = await apiGetAllAdsAsync();
+ const adsData = adsResponse.data || [];
+ setAds(adsData);
+ updateFunnelData(adsData);
+ };
+
+ fetchData();
+
+ const jwtToken = localStorage.getItem('token');
+ if (!jwtToken) return;
+
+ const newConnection = new HubConnectionBuilder()
+ .withUrl(HUB_URL, {
+ accessTokenFactory: () => jwtToken,
+ })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(LogLevel.Information)
+ .build();
+
+ connectionRef.current = newConnection;
+
+ const startConnection = async () => {
+ try {
+ await newConnection.start();
+ console.log('SignalR Connected to AdvertisementHub!');
+ } catch (err) {
+ console.error('SignalR Connection Error:', err);
+ }
+ };
+
+ startConnection();
+
+ newConnection.on('ReceiveAdUpdate', (updatedAd) => {
+ setAds((prevAds) => {
+ const existingAdIndex = prevAds.findIndex(
+ (ad) => ad.id === updatedAd.id
+ );
+ const updatedAds = [...prevAds];
+
+ if (existingAdIndex !== -1) {
+ updatedAds[existingAdIndex] = updatedAd;
+ } else {
+ updatedAds.push(updatedAd);
+ }
+
+ updateFunnelData(updatedAds);
+ return updatedAds;
+ });
+ });
+
+ return () => {
+ if (
+ connectionRef.current &&
+ connectionRef.current.state === 'Connected'
+ ) {
+ connectionRef.current
+ .stop()
+ .catch((err) =>
+ console.error('Error stopping SignalR connection:', err)
+ );
+ }
+ };
+ }, []);
+
+ const updateFunnelData = (adsData) => {
+ const totalViews = adsData.reduce((sum, ad) => sum + (ad.views || 0), 0);
+ const totalClicks = adsData.reduce((sum, ad) => sum + (ad.clicks || 0), 0);
+ const totalConversions = adsData.reduce(
+ (sum, ad) => sum + (ad.conversions || 0),
+ 0
+ );
+
+ setFunnelSteps([
+ {
+ label: t('analytics.viewed'),
+ value: totalViews,
+ percent: 100,
+ color: funnelColors[0],
+ icon: funnelIcons[0],
+ },
+ {
+ label: t('analytics.clicked'),
+ value: totalClicks,
+ percent:
+ totalViews > 0 ? Math.round((totalClicks / totalViews) * 100) : 0,
+ color: funnelColors[1],
+ icon: funnelIcons[1],
+ },
+ {
+ label: t('analytics.converted'),
+ value: totalConversions,
+ percent:
+ totalClicks > 0
+ ? Math.round((totalConversions / totalClicks) * 100)
+ : 0,
+ color: funnelColors[2],
+ icon: funnelIcons[2],
+ },
+ ]);
+ };
+
+ return (
+
+
+ {t('analytics.salesFunnelAnalysis')}
+
+
+
+
+
+
+
+ {funnelSteps.map((step) => (
+
+
+
+ {step.icon}
+
+
+ {step.value.toLocaleString()}
+
+
+ {step.label}
+
+
+ {step.percent}% from previous
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/AdRealtimeMonitor.jsx b/src/components/AdRealtimeMonitor.jsx
new file mode 100644
index 0000000..dfe7e29
--- /dev/null
+++ b/src/components/AdRealtimeMonitor.jsx
@@ -0,0 +1,45 @@
+// AdRealtimeMonitor.jsx
+import React from 'react';
+import { useAdSignalR } from '../hooks/useAdSignalR'; // putanja do custom hooka
+import { useTranslation } from 'react-i18next';
+export default function AdRealtimeMonitor() {
+ const {
+ connectionStatus,
+ latestAdUpdate,
+ latestClickTime,
+ latestViewTime,
+ latestConversionTime,
+ adUpdatesHistory,
+ } = useAdSignalR();
+
+ const { t } = useTranslation();
+
+ return (
+
+ Status: {connectionStatus}
+
+ {t('common.latestAdUpdate')}:{' '}
+ {latestAdUpdate ? JSON.stringify(latestAdUpdate) : 'None'}
+
+
+ {t('common.latestClick')}: {latestClickTime}
+
+
+ {t('common.latestView')}: {latestViewTime}
+
+
+ {t('common.latestConversion')}: {latestConversionTime}
+
+
+ {t('common.history')}:
+
+ {adUpdatesHistory.map((item, idx) => (
+ -
+ [{item.type}] {item.data} ({item.time.toLocaleString()})
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/AdStackedBarChart.jsx b/src/components/AdStackedBarChart.jsx
new file mode 100644
index 0000000..16cce3b
--- /dev/null
+++ b/src/components/AdStackedBarChart.jsx
@@ -0,0 +1,201 @@
+import React, { useEffect, useState } from 'react';
+import { Card, Typography, Box } from '@mui/material';
+import { apiGetAllAdsAsync } from '../api/api.js';
+import { format, parseISO } from 'date-fns';
+import { useTranslation } from 'react-i18next';
+
+const colors = ['#6366F1', '#F59E0B'];
+const labels = ['Fixed', 'PopUp'];
+
+const barHeight = 50;
+const barGap = 45;
+const chartWidth = 200;
+const yAxisWidth = 90;
+const overlapRadius = 20;
+const framePadding = 10;
+
+function groupByMonthAndType(ads) {
+ const byMonth = {};
+ ads.forEach((ad) => {
+ const date = ad.startTime || ad.endTime;
+ if (!date) return;
+ const month = format(parseISO(date), 'yyyy-MM');
+ if (!byMonth[month]) byMonth[month] = { year: month, Fixed: 0, PopUp: 0 };
+ if (ad.adType === 'Fixed') byMonth[month].Fixed += 1;
+ if (ad.adType === 'PopUp') byMonth[month].PopUp += 1;
+ });
+
+ // Sortiraj po mjesecima
+ const sortedMonths = Object.values(byMonth).sort((a, b) =>
+ a.year.localeCompare(b.year)
+ );
+
+ // Uzmi samo zadnja tri mjeseca
+ return sortedMonths.slice(-3);
+}
+
+
+function StackedBarRow({
+ row,
+ y,
+ maxTotal,
+ chartWidth,
+ overlapRadius,
+ framePadding,
+ strokeWidth = 4,
+}) {
+ const keys = ['Fixed', 'PopUp'];
+ const total = keys.reduce((sum, k) => sum + row[k], 0);
+ const barWidth = total > 0 ? (total / maxTotal) * chartWidth : 0;
+ let acc = 0;
+ const segmentPositions = [];
+
+ keys.forEach((k, idx) => {
+ const value = row[k];
+ const start = acc;
+ acc += value;
+ const x = (start / total) * barWidth + yAxisWidth;
+ const w = (value / total) * barWidth;
+ segmentPositions.push({ x, w });
+ });
+
+ return (
+
+
+ {['Fixed', 'PopUp']
+ .map((k, idx) => {
+ const { x, w } = segmentPositions[idx];
+ return (
+
+ );
+ })
+ .reverse()}
+
+ );
+}
+
+export default function AdStackedBarChart() {
+ const { t } = useTranslation();
+ const [data, setData] = useState([]);
+ useEffect(() => {
+ const fetchData = async () => {
+ const adsResponse = await apiGetAllAdsAsync();
+ const ads = adsResponse.data;
+ const grouped = groupByMonthAndType(ads);
+ setData(grouped);
+ };
+ fetchData();
+ }, []);
+
+ const keys = ['Fixed', 'PopUp'];
+ const totals = data.map((row) => keys.reduce((sum, k) => sum + row[k], 0));
+ const maxTotal = Math.max(...totals, 1); // da ne bude 0
+
+ const chartHeight = data.length * (barHeight + barGap);
+
+ return (
+
+
+ {t('analytics.combinationChart')}
+
+
+
+ {labels.map((label, idx) => (
+
+
+ {t(`analytics.${label.toLowerCase()}`)}
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/AddAdItemModal.jsx b/src/components/AddAdItemModal.jsx
new file mode 100644
index 0000000..bd914c2
--- /dev/null
+++ b/src/components/AddAdItemModal.jsx
@@ -0,0 +1,167 @@
+import React, { useState } from 'react';
+import {
+ Modal,
+ Box,
+ TextField,
+ MenuItem,
+ Typography,
+ Button,
+} from '@mui/material';
+import ImageUploader from './ImageUploader';
+import { apiGetStoreProductsAsync } from '@api/api';
+
+const AddAdItemModal = ({ open, onClose, onAddItem, stores }) => {
+ const [formData, setFormData] = useState({
+ Image: '',
+ StoreLink: '',
+ ProductLink: '',
+ Description: ''
+ });
+
+ const [errors, setErrors] = useState({});
+ const [products, setProducts] = useState([]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const handleStoreChange = async (e) => {
+ const selectedStoreLink = e.target.value;
+ setFormData((prev) => ({
+ ...prev,
+ StoreLink: selectedStoreLink,
+ }));
+ try {
+ const result = await apiGetStoreProductsAsync(selectedStoreLink);
+ setProducts(result.data || []);
+ } catch (err) {
+ console.error('Failed to fetch products for store:', err);
+ }
+ };
+
+ const handleImageUpload = (files) => {
+ const file = files[0];
+ if (file) {
+ setFormData((prev) => ({ ...prev, Image: file }));
+ }
+ };
+
+ const handleSubmit = () => {
+ const err = {};
+ if (!formData.Description.trim()) err.Description = 'Required';
+ if (!formData.ProductLink) err.ProductLink = 'Required';
+ if (!formData.StoreLink) err.StoreLink = 'Required';
+ if (!formData.Image) err.Image = 'Image is required';
+ setErrors(err);
+ if (Object.keys(err).length > 0) return;
+
+ onAddItem(formData);
+
+ setFormData({
+ Image: '',
+ StoreLink: '',
+ ProductLink: '',
+ Description: '',
+ AdType: '',
+ Triggers: [],
+ });
+
+ onClose();
+ };
+
+ return (
+
+
+ {/* Left: Image Uploader */}
+
+
+ {errors.Image && (
+
+ {errors.Image}
+
+ )}
+
+
+ {/* Right: Form Fields */}
+
+
+ Add Ad Item
+
+
+
+
+
+ {products.map((p) => (
+
+ ))}
+
+
+
+ {stores.map((s) => (
+
+ ))}
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+ );
+};
+
+export default AddAdItemModal;
diff --git a/src/components/AddAdModal.jsx b/src/components/AddAdModal.jsx
new file mode 100644
index 0000000..0158760
--- /dev/null
+++ b/src/components/AddAdModal.jsx
@@ -0,0 +1,411 @@
+import React, { useState, useEffect } from 'react';
+import { Select, Checkbox, ListItemText } from '@mui/material';
+import {
+ Modal,
+ Box,
+ Typography,
+ TextField,
+ Button,
+ MenuItem,
+ InputAdornment,
+} from '@mui/material';
+import SellIcon from '@mui/icons-material/Sell';
+import AddAdItemModal from './AddAdItemModal';
+import {
+ apiGetAllStoresAsync,
+ apiFetchApprovedUsersAsync,
+ apiCreateAdAsync,
+} from '@api/api';
+import { useTranslation } from 'react-i18next';
+
+const triggerArrayToBitmask = (arr) => {
+ const triggerMap = {
+ View: 1,
+ Search: 2,
+ Order: 4,
+ };
+ return arr.reduce((sum, val) => sum | (triggerMap[val] || 0), 0);
+};
+
+const AddAdModal = ({ open, onClose, onAddAd }) => {
+ const { t } = useTranslation();
+ const [formData, setFormData] = useState({
+ sellerId: '',
+ Views: 0,
+ Clicks: 0,
+ Conversions: 0,
+ clickPrice: '',
+ viewPrice: '',
+ conversionPrice: '',
+ startTime: '',
+ endTime: '',
+ isActive: true,
+ AdData: [],
+ AdType: '',
+ Triggers: [],
+ });
+
+ const [stores, setStores] = useState([]);
+ const [sellers, setSellers] = useState([]);
+ const [formErrors, setFormErrors] = useState({});
+ const [adItemModalOpen, setAdItemModalOpen] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ apiGetAllStoresAsync().then(setStores);
+ apiFetchApprovedUsersAsync().then((users) => {
+ const sellersOnly = users.filter((u) => {
+ const role = (u.roles?.[0] || 'buyer').toLowerCase();
+ return role === 'seller';
+ });
+ setSellers(sellersOnly);
+ });
+ }
+ }, [open]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({
+ ...prev,
+ [name]: value.toString(),
+ }));
+ };
+
+ const handleAddAdItem = (item) => {
+ setFormData((prev) => ({
+ ...prev,
+ AdData: [...prev.AdData, item],
+ }));
+ };
+ const handleAdType = (e) => {
+ const value = e.target.value.toString();
+ setFormData((prev) => ({
+ ...prev,
+ AdType: value,
+ }));
+ };
+
+ const handleTriggers = (e) => {
+ const value = e.target.value.toString();
+ if (!formData.Triggers.includes(value)) {
+ setFormData((prev) => ({
+ ...prev,
+ Triggers: [...prev.Triggers, value],
+ }));
+ }
+ };
+ const handleSubmit = async () => {
+ const errors = {};
+ console.log('[DEBUG] Raw form data before validation:', formData);
+ if (!formData.sellerId) errors.sellerId = 'Seller is required';
+ if (!formData.startTime) errors.startTime = 'Start time is required';
+ if (!formData.endTime) {
+ errors.endTime = 'End time is required';
+ } else if (formData.startTime && formData.endTime <= formData.startTime) {
+ errors.endTime = 'End time must be after start time';
+ }
+
+ if (!formData.clickPrice) errors.clickPrice = 'Click price required';
+ if (!formData.viewPrice) errors.viewPrice = 'View price required';
+ if (!formData.conversionPrice) errors.conversionPrice = 'Conversion price required';
+ if (!formData.AdType) errors.AdType = 'Ad Type is required';
+ if (formData.Triggers.length === 0) errors.Triggers = 'At least one trigger required';
+ if (formData.AdData.length === 0) errors.AdData = 'At least one ad item required';
+
+ setFormErrors(errors);
+ if (Object.keys(errors).length > 0) return;
+ if (Object.keys(errors).length > 0) {
+ console.warn('[DEBUG] Validation errors:', errors);
+ return;
+
+ console.log('AdType being sent:', formData.AdType);
+}
+
+ const result = {
+ sellerId: formData.sellerId,
+ startTime: formData.startTime,
+ endTime: formData.endTime,
+ clickPrice: parseFloat(formData.clickPrice),
+ viewPrice: parseFloat(formData.viewPrice),
+ conversionPrice: parseFloat(formData.conversionPrice),
+ AdType: formData.AdType,
+ Triggers: formData.Triggers,
+ AdData: formData.AdData,
+ isActive: formData.isActive,
+ };
+ setFormData({
+ sellerId: '',
+ startTime: '',
+ endTime: '',
+ clickPrice: '',
+ viewPrice: '',
+ conversionPrice: '',
+ AdData: [],
+ AdType: '',
+ Triggers: [],
+ isActive: true,
+ });
+ onAddAd(result)
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ {t('common.createAd')}
+
+
+
+
+
+
+ {sellers.map((seller) => (
+
+ ))}
+
+
+
+
+
+
+ {formErrors.AdData && (
+
+ {formErrors.AdData}
+
+ )}
+
+
+
+ KM,
+ }}
+ />
+
+ KM,
+ }}
+ />
+
+ KM,
+ }}
+ />
+
+
+
+
+
+
+ selected.join(', '),
+ }}
+ name="Triggers"
+ label={t('common.triggers')}
+ value={Array.isArray(formData.Triggers) ? formData.Triggers : []}
+ onChange={(e) => {
+ const { value } = e.target;
+ setFormData((prev) => ({
+ ...prev,
+ Triggers: typeof value === 'string' ? value.split(',') : value,
+ }));
+ }}
+ fullWidth
+ margin="dense"
+ error={!!formErrors.Triggers}
+ helperText={formErrors.Triggers}
+>
+ {['Search', 'Order', 'View'].map((trigger) => (
+
+ ))}
+
+
+ {formData.AdData.map((item, index) => (
+
+
+ {t('common.adText')}: {item.Description}
+
+
+ {t('common.store')}: {item.StoreLink}
+
+
+ {t('common.product')}: {item.ProductLink}
+
+
+ ))}
+
+
+
+
+
+
+
+ setAdItemModalOpen(false)}
+ onAddItem={handleAddAdItem}
+ stores={stores}
+ />
+
+
+ );
+};
+
+export default AddAdModal;
diff --git a/src/components/AddCategoryModal.jsx b/src/components/AddCategoryModal.jsx
new file mode 100644
index 0000000..b16686e
--- /dev/null
+++ b/src/components/AddCategoryModal.jsx
@@ -0,0 +1,121 @@
+import React, { useState } from "react";
+import {
+ Modal,
+ Box,
+ TextField,
+ Button,
+ Typography,
+ RadioGroup,
+ FormControlLabel,
+ Radio,
+ FormLabel,
+ Avatar,
+} from "@mui/material";
+import CategoryIcon from "@mui/icons-material/Category";
+
+const AddCategoryModal = ({ open, onClose, onAddCategory, selectedType }) => {
+ const [categoryName, setCategoryName] = useState("");
+ const [categoryType, setCategoryType] = useState("product");
+
+ const handleSubmit = () => {
+ console.log(selectedType);
+ if (categoryName.trim()) {
+ const newCategory = {
+ id: Date.now(),
+ name: categoryName.trim(),
+ type: categoryType,
+ };
+ onAddCategory(newCategory);
+ setCategoryName("");
+ onClose();
+ }
+};
+
+ return (
+
+
+ {/* Ikonica iznad */}
+
+
+
+
+ {/* Naslov */}
+
+ Add New Category
+
+
+ {/* Input za ime */}
+ setCategoryName(e.target.value)}
+ sx={{ mb: 3 }}
+ />
+
+ {/* Radio grupa za tip */}
+
+ Category Type
+ setCategoryType(e.target.value)}
+ >
+ }
+ label="Product"
+ />
+ }
+ label="Store"
+ />
+
+
+
+ {/* Dugmad */}
+
+
+
+
+
+
+ );
+};
+
+export default AddCategoryModal;
diff --git a/src/components/AddStoreModal.jsx b/src/components/AddStoreModal.jsx
new file mode 100644
index 0000000..593fbba
--- /dev/null
+++ b/src/components/AddStoreModal.jsx
@@ -0,0 +1,175 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Modal,
+ Box,
+ Typography,
+ TextField,
+ Button,
+ MenuItem,
+} from '@mui/material';
+import StoreMallDirectoryIcon from '@mui/icons-material/StoreMallDirectory';
+import { apiGetStoreCategoriesAsync, apiFetchGeographyAsync } from '@api/api';
+import { useTranslation } from 'react-i18next';
+
+
+const AddStoreModal = ({ open, onClose, onAddStore }) => {
+ const { t } = useTranslation();
+ const [formData, setFormData] = useState({
+ name: '',
+ address: '',
+ description: '',
+ categoryid: '',
+ placeId: '',
+ isActive: true,
+ });
+
+ const [categories, setCategories] = useState([]);
+ const [places, setPlaces] = useState([]);
+
+ useEffect(() => {
+ if (open) {
+ apiGetStoreCategoriesAsync().then(setCategories);
+ apiFetchGeographyAsync().then((geo) => {
+ setPlaces(geo?.places || []);
+ });
+ }
+ }, [open]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({
+ ...prev,
+ [name]: name === 'isActive' ? value === 'true' : value,
+ }));
+ };
+
+ const handleSubmit = () => {
+ onAddStore(formData);
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ {t('common.addNewStore')}
+
+
+
+
+
+
+
+
+
+
+ {places.map((place) => (
+
+ ))}
+
+
+
+ {categories.map((cat) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default AddStoreModal;
diff --git a/src/components/AddUserModal.jsx b/src/components/AddUserModal.jsx
new file mode 100644
index 0000000..ca89c9d
--- /dev/null
+++ b/src/components/AddUserModal.jsx
@@ -0,0 +1,149 @@
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ Button,
+ Box,
+ Typography,
+ RadioGroup,
+ FormControlLabel,
+ Radio,
+ Avatar,
+} from "@mui/material";
+import PersonIcon from "@mui/icons-material/Person";
+
+const AddUserModal = ({ open, onClose, onCreate }) => {
+ const [formData, setFormData] = useState({
+ userName: "",
+ email: "",
+ password: "",
+ role: "Buyer",
+ });
+
+ useEffect(() => {
+ if (open) {
+ setFormData({
+ userName: "",
+ email: "",
+ password: "",
+ role: "Buyer",
+ });
+ }
+ }, [open]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({
+ ...prev,
+ [name]: value,
+ }));
+ };
+
+ const handleSubmit = () => {
+ if (
+ formData.userName.trim() &&
+ formData.email.trim() &&
+ formData.password.trim()
+ ) {
+ onCreate(formData);
+ onClose();
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default AddUserModal;
diff --git a/src/components/AdminSearchBar.jsx b/src/components/AdminSearchBar.jsx
new file mode 100644
index 0000000..fed898b
--- /dev/null
+++ b/src/components/AdminSearchBar.jsx
@@ -0,0 +1,32 @@
+import React from "react";
+import { TextField, InputAdornment } from "@mui/material";
+import { FiSearch } from "react-icons/fi";
+
+const SearchBar = ({ placeholder = "Search", onChange, value }) => {
+ return (
+
+
+
+ ),
+ }}
+ />
+ );
+};
+
+export default SearchBar;
diff --git a/src/components/AdvertisementDetailsModal.jsx b/src/components/AdvertisementDetailsModal.jsx
new file mode 100644
index 0000000..7f619df
--- /dev/null
+++ b/src/components/AdvertisementDetailsModal.jsx
@@ -0,0 +1,283 @@
+import React, { useState, useEffect } from 'react';
+import { Modal, Box, Typography } from '@mui/material';
+import {
+ Eye, Hand, CheckCircle, BarChart2,
+ MousePointerClick, Percent, Activity
+} from 'lucide-react';
+import CountUp from 'react-countup';
+import AdContentCard from '@components/AdContentCard';
+import HorizontalScroll from './HorizontalScroll';
+import { apiGetAllStoresAsync, apiGetStoreProductsAsync } from '@api/api';
+import { useAdSignalR } from '@hooks/useAdSignalR';
+import { useTranslation } from 'react-i18next';
+
+const AdvertisementDetailsModal = ({ open, onClose, ad, onSave, onDelete }) => {
+ const { t } = useTranslation();
+ const [isEditing, setIsEditing] = useState(false);
+ const [editedData, setEditedData] = useState({
+ adData: ad?.adData || [],
+ startTime: ad?.startTime || '',
+ endTime: ad?.endTime || '',
+ isActive: ad?.isActive || false,
+ });
+
+ const [stores, setStores] = useState([]);
+ const [products, setProducts] = useState([]);
+
+ const {
+ connectionStatus,
+ latestAdUpdate,
+ latestClickTime,
+ latestViewTime,
+ latestConversionTime,
+ adUpdatesHistory,
+ } = useAdSignalR();
+
+ const adToShow = latestAdUpdate?.id === ad?.id ? latestAdUpdate : ad;
+
+ useEffect(() => {
+ if (open) {
+ const fetchData = async () => {
+ const fetchedStores = await apiGetAllStoresAsync();
+ setStores(fetchedStores);
+
+ const allProducts = [];
+ for (const store of fetchedStores) {
+ const { data } = await apiGetStoreProductsAsync(store.id);
+ allProducts.push(...data);
+ }
+ setProducts(allProducts);
+ };
+ fetchData();
+ }
+ }, [open]);
+
+ const getStoreName = (storeId) =>
+ stores.find((s) => s.id === storeId)?.name || `Unknown store`;
+
+ const getProductName = (productId) =>
+ products.find((p) => p.id === productId)?.name || `Unknown product`;
+
+ const handleSave = () => {
+ onSave?.(ad.id, editedData);
+ setIsEditing(false);
+ };
+
+ const handleCancel = () => {
+ setEditedData({
+ adData: ad.adData,
+ startTime: ad.startTime,
+ endTime: ad.endTime,
+ isActive: ad.isActive,
+ });
+ setIsEditing(false);
+ };
+
+ const updateAdData = (index, field, value) => {
+ const newAdData = [...editedData.adData];
+ newAdData[index] = { ...newAdData[index], [field]: value };
+ setEditedData({ ...editedData, adData: newAdData });
+ };
+
+ if (!adToShow) {
+ return <>>; // siguran render bez hook greške
+ }
+
+ const cardData = [
+ {
+ icon: ,
+ label: 'Views',
+ value: adToShow.views.toLocaleString(),
+ bg: '#e0f2fe',
+ },
+ {
+ icon: ,
+ label: 'Clicks',
+ value: adToShow.clicks.toLocaleString(),
+ bg: '#ccfbf1',
+ },
+ {
+ icon: ,
+ label: 'CTR',
+ value:
+ adToShow.views > 0
+ ? ((adToShow.clicks / adToShow.views) * 100).toFixed(1) + '%'
+ : '0%',
+ bg: '#fef9c3',
+ },
+ {
+ icon: ,
+ label: 'Status',
+ value: adToShow.isActive ? 'Active' : 'Inactive',
+ bg: adToShow.isActive ? '#dcfce7' : '#fee2e2',
+ },
+ ];
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+ Advertisement Overview
+
+
+ Advertisement {adToShow.id}
+
+
+ Seller ID: {adToShow.sellerId}
+
+
+
+
+
+ {/* Stats Cards */}
+
+ {cardData.map((item, i) => (
+
+ {item.icon}
+
+ {item.label}
+
+
+ {item.value}
+
+
+ ))}
+
+
+ {/* Time Info */}
+
+ {[{ label: 'Start Time', time: adToShow.startTime }, { label: 'End Time', time: adToShow.endTime }].map(({ label, time }, idx) => (
+
+
+
+ {label}
+
+
+ {new Date(time).toLocaleDateString()}
+
+
+ {new Date(time).toLocaleTimeString()}
+
+
+ ))}
+
+
+ {/* Prices */}
+
+
+ {t('common.clickPrice')}: {adToShow.clickPrice ?? '1000'}
+
+
+ {t('common.viewPrice')}: {adToShow.viewPrice ?? '1000'}
+
+
+ {t('common.conversionPrice')}: {adToShow.conversionPrice ?? '1000'}
+
+
+
+ {/* Content Section */}
+
+
+ {t('common.advertisementContent')}
+
+
+ {adToShow.adData.map((item, index) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+const styles = {
+ modal: {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: '90%',
+ maxWidth: 1000,
+ maxHeight: '90vh',
+ overflowY: 'auto',
+ bgcolor: '#fff',
+ borderRadius: 3,
+ p: 4,
+ outline: 'none',
+ },
+ headerBox: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ mb: 4,
+ },
+ headerAccent: {
+ width: 12,
+ height: 48,
+ borderRadius: '50px',
+ background: 'linear-gradient(to bottom, #facc15, #f97316)',
+ mx: 1,
+ },
+ headerContent: {
+ flex: 1,
+ textAlign: 'center',
+ },
+ cardGrid: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ gap: 2,
+ mb: 4,
+ },
+ card: {
+ flex: 1,
+ borderRadius: 2,
+ p: 2,
+ textAlign: 'center',
+ boxShadow: '0 2px 6px rgba(0,0,0,0.06)',
+ },
+ timeCard: {
+ flex: 1,
+ backgroundColor: '#fff7ed',
+ borderRadius: 2,
+ p: 3,
+ boxShadow: '0 2px 8px rgba(0,0,0,0.05)',
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ gap: 0.5,
+ },
+ timeTitle: {
+ display: 'flex',
+ alignItems: 'center',
+ fontWeight: 600,
+ color: '#FF8000',
+ mb: 1,
+ },
+};
+
+export default AdvertisementDetailsModal;
\ No newline at end of file
diff --git a/src/components/AnalyticsChart.jsx b/src/components/AnalyticsChart.jsx
new file mode 100644
index 0000000..608242d
--- /dev/null
+++ b/src/components/AnalyticsChart.jsx
@@ -0,0 +1,254 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { Tabs, Tab, Box, Typography } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Legend,
+} from 'recharts';
+import { apiGetAllAdsAsync } from '../api/api.js';
+import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
+
+const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
+const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub';
+const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`;
+
+function getLast12Months() {
+ const months = [];
+ const now = new Date();
+ for (let i = 11; i >= 0; i--) {
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
+ months.push(
+ d.toLocaleString('default', { month: 'short', year: 'numeric' })
+ );
+ }
+ return months;
+}
+
+function generateTargets(realValues, minOffset = -0.1, maxOffset = 0.15) {
+ return realValues.map((item) => {
+ const offset = minOffset + Math.random() * (maxOffset - minOffset);
+ return Math.round(item * (1 + offset));
+ });
+}
+
+const AdsRevenueChart = () => {
+ const { t } = useTranslation();
+ const [tab, setTab] = useState(0);
+ const [chartData, setChartData] = useState({
+ conversions: [],
+ clicks: [],
+ views: [],
+ });
+ const [ads, setAds] = useState([]);
+ const connectionRef = useRef(null);
+
+ // Helper to recalculate chart data
+ const calculateChartData = (ads) => {
+ const months = getLast12Months();
+
+ const revenueData = {
+ conversions: Array(12).fill(0),
+ clicks: Array(12).fill(0),
+ views: Array(12).fill(0),
+ };
+
+ for (const ad of ads) {
+ const startDate = new Date(ad.startTime);
+ const endDate = new Date(ad.endTime);
+
+ for (let i = 0; i < 12; i++) {
+ const monthStart = new Date();
+ monthStart.setMonth(monthStart.getMonth() - (11 - i), 1);
+ const monthEnd = new Date(monthStart);
+ monthEnd.setMonth(monthEnd.getMonth() + 1);
+
+ if (startDate < monthEnd && endDate >= monthStart) {
+ revenueData.conversions[i] +=
+ (ad.conversions || 0) * (ad.conversionPrice || 0);
+ revenueData.clicks[i] += (ad.clicks || 0) * (ad.clickPrice || 0);
+ revenueData.views[i] += (ad.views || 0) * (ad.viewPrice || 0);
+ }
+ }
+ }
+
+ // Generate targets
+ const conversionsTargets = generateTargets(revenueData.conversions);
+ const clicksTargets = generateTargets(revenueData.clicks);
+ const viewsTargets = generateTargets(revenueData.views);
+
+ // Prepare final chart data
+ setChartData({
+ conversions: months.map((month, i) => ({
+ month,
+ revenue: revenueData.conversions[i],
+ target: conversionsTargets[i],
+ })),
+ clicks: months.map((month, i) => ({
+ month,
+ revenue: revenueData.clicks[i],
+ target: clicksTargets[i],
+ })),
+ views: months.map((month, i) => ({
+ month,
+ revenue: revenueData.views[i],
+ target: viewsTargets[i],
+ })),
+ });
+ };
+
+ useEffect(() => {
+ const fetchInitialData = async () => {
+ try {
+ const adsResponse = await apiGetAllAdsAsync();
+ const adsData = adsResponse.data || [];
+ setAds(adsData);
+ calculateChartData(adsData);
+ } catch (error) {
+ console.error('Error fetching initial ads data:', error);
+ }
+ };
+
+ fetchInitialData();
+
+ // Initialize SignalR connection
+ const jwtToken = localStorage.getItem('token');
+ if (!jwtToken) {
+ console.warn('No JWT token found. SignalR connection not started.');
+ return;
+ }
+
+ const newConnection = new HubConnectionBuilder()
+ .withUrl(HUB_URL, {
+ accessTokenFactory: () => jwtToken,
+ })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(LogLevel.Information)
+ .build();
+
+ connectionRef.current = newConnection;
+
+ const startConnection = async () => {
+ try {
+ await newConnection.start();
+ console.log('SignalR Connected to AdvertisementHub!');
+ } catch (err) {
+ console.error('SignalR Connection Error:', err);
+ }
+ };
+
+ startConnection();
+
+ // Register event handlers
+ newConnection.on('ReceiveAdUpdate', (updatedAd) => {
+ console.log('Received Ad Update:', updatedAd);
+ setAds((prevAds) => {
+ const updatedAds = prevAds.map((ad) =>
+ ad.id === updatedAd.id ? updatedAd : ad
+ );
+
+ if (!updatedAds.some((ad) => ad.id === updatedAd.id)) {
+ updatedAds.push(updatedAd);
+ }
+
+ calculateChartData(updatedAds);
+ return updatedAds;
+ });
+ });
+
+ // Cleanup on unmount
+ return () => {
+ if (
+ connectionRef.current &&
+ connectionRef.current.state === 'Connected'
+ ) {
+ console.log('Stopping SignalR connection on component unmount.');
+ connectionRef.current
+ .stop()
+ .catch((err) =>
+ console.error('Error stopping SignalR connection:', err)
+ );
+ }
+ };
+ }, []);
+
+ const handleChange = (event, newValue) => {
+ setTab(newValue);
+ };
+
+ return (
+
+
+
+ {tab === 0 && t('analytics.conversionsRevenue')}
+ {tab === 1 && t('analytics.clicksRevenue')}
+ {tab === 2 && t('analytics.viewsRevenue')}
+
+
+
+
+
+
+
+
+
+
+
+
+ `$${Math.round(v / 1000)}K`} />
+ `$${val}`} />
+
+
+
+
+
+
+ );
+};
+
+export default AdsRevenueChart;
diff --git a/src/components/ApproveUserButton.jsx b/src/components/ApproveUserButton.jsx
new file mode 100644
index 0000000..0a09060
--- /dev/null
+++ b/src/components/ApproveUserButton.jsx
@@ -0,0 +1,10 @@
+import { IconButton } from "@mui/material";
+import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+
+export default function ApproveUserButton({ onClick }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/Calendar.jsx b/src/components/Calendar.jsx
new file mode 100644
index 0000000..a9893d9
--- /dev/null
+++ b/src/components/Calendar.jsx
@@ -0,0 +1,273 @@
+import React, { useState } from 'react';
+import {
+ Paper,
+ Box,
+ IconButton,
+ Typography,
+ styled,
+ Button, // From HEAD
+ Grid,
+ useTheme,
+} from '@mui/material';
+import {
+ ChevronLeft,
+ ChevronRight,
+ ZoomIn,
+ ZoomOut,
+ Remove,
+} from '@mui/icons-material';
+import dayjs from 'dayjs';
+
+const CalendarCell = styled(Box)(
+ ({ theme, isToday, isSelected, isCurrentMonth }) => ({
+ width: 36,
+ height: 36,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ cursor: 'pointer',
+ borderRadius: '50%',
+ transition: 'all 0.2s ease',
+ color: !isCurrentMonth
+ ? theme.palette.text.disabled
+ : isToday
+ ? theme.palette.primary.main
+ : theme.palette.text.primary,
+ backgroundColor: isSelected ? theme.palette.primary.main : 'transparent',
+ '&:hover': {
+ backgroundColor: isSelected
+ ? theme.palette.primary.dark // Slightly darker hover for selected
+ : isCurrentMonth
+ ? theme.palette.action.hover
+ : 'transparent', // Only hover current month days
+ },
+ ...(isSelected && {
+ color: theme.palette.primary.contrastText,
+ }),
+ ...(isToday &&
+ !isSelected && {
+ border: `1px solid ${theme.palette.primary.main}`,
+ }),
+ ...(!isCurrentMonth && {
+ // Ensure non-current month days are not interactive on hover
+ pointerEvents: 'none',
+ }),
+ })
+);
+
+const DayHeader = styled(Typography)(({ theme }) => ({
+ // Added theme for consistency
+ fontSize: '0.875rem',
+ fontWeight: 500,
+ textAlign: 'center',
+ color: theme.palette.text.secondary, // Using theme for color
+}));
+
+function Calendar() {
+ const theme = useTheme();
+ const today = dayjs();
+ const [currentDate, setCurrentDate] = useState(today);
+ // Default to no dates selected or just today if that's the desired default
+ const [selectedDates, setSelectedDates] = useState([
+ today.format('YYYY-MM-DD'),
+ ]);
+ // const [selectedDates, setSelectedDates] = useState([]); // Alternative: start with no selection
+
+ // Generate calendar days
+ const generateCalendarDays = () => {
+ const firstDayOfMonth = currentDate.startOf('month');
+ const daysInMonth = currentDate.daysInMonth();
+ // Consistent startDay logic (Monday = 0)
+ const startDay =
+ firstDayOfMonth.day() === 0 ? 6 : firstDayOfMonth.day() - 1;
+
+ const prevMonthDays = [];
+ for (let i = 0; i < startDay; i++) {
+ // Corrected loop for prev month days
+ const date = firstDayOfMonth.subtract(startDay - i, 'day');
+ prevMonthDays.push({
+ day: date.date(),
+ isCurrentMonth: false,
+ date: date.format('YYYY-MM-DD'),
+ isToday: date.isSame(today, 'day'),
+ });
+ }
+
+ const currentMonthDays = [];
+ for (let i = 1; i <= daysInMonth; i++) {
+ const date = firstDayOfMonth.date(i); // Simpler way to get date in current month
+ currentMonthDays.push({
+ day: i,
+ isCurrentMonth: true,
+ date: date.format('YYYY-MM-DD'),
+ isToday: date.isSame(today, 'day'),
+ });
+ }
+
+ const totalCells = 42; // Standard 6 weeks * 7 days
+ const remainingCells =
+ totalCells - (prevMonthDays.length + currentMonthDays.length);
+ const nextMonthDays = [];
+ const lastDayOfCurrentMonth = currentDate.endOf('month'); // For calculating next month days
+
+ for (let i = 1; i <= remainingCells; i++) {
+ const date = lastDayOfCurrentMonth.add(i, 'day');
+ nextMonthDays.push({
+ day: date.date(),
+ isCurrentMonth: false,
+ date: date.format('YYYY-MM-DD'),
+ isToday: date.isSame(today, 'day'),
+ });
+ }
+
+ return [...prevMonthDays, ...currentMonthDays, ...nextMonthDays];
+ };
+
+ const days = generateCalendarDays();
+ const weekDays = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
+
+ const handleDateClick = (dateStr, isCurrentMonthCell) => {
+ if (!isCurrentMonthCell) return; // Only allow selecting dates in the current month
+
+ if (selectedDates.includes(dateStr)) {
+ setSelectedDates(selectedDates.filter((d) => d !== dateStr));
+ } else {
+ // If you want single selection, uncomment next line and comment out the one after
+ // setSelectedDates([dateStr]);
+ setSelectedDates([...selectedDates, dateStr]); // For multi-selection
+ }
+ };
+
+ const handlePrevMonth = () => {
+ setCurrentDate(currentDate.subtract(1, 'month'));
+ };
+
+ const handleNextMonth = () => {
+ setCurrentDate(currentDate.add(1, 'month'));
+ };
+
+ // Placeholder for zoom functionality if needed
+ const handleZoom = (type) => {
+ console.log(`Zoom ${type} clicked`);
+ };
+
+ return (
+
+ {/* Calendar Header: Month/Year and Navigation */}
+
+
+
+
+
+ {currentDate.format('MMMM YYYY')}
+
+
+
+
+
+
+ {/* Weekday headers */}
+
+ {' '}
+ {/* Adjusted spacing and padding */}
+ {weekDays.map((day) => (
+ // Each day header takes up 1/7th of the width
+
+ {day}
+
+ ))}
+
+
+ {/* Calendar grid */}
+
+ {' '}
+ {/* Allow grid to take remaining space */}
+ {days.map((dayInfo, index) => (
+ // Each cell takes up 1/7th of the width
+
+
+ handleDateClick(dayInfo.date, dayInfo.isCurrentMonth)
+ }
+ >
+ {dayInfo.day}
+
+
+ ))}
+
+
+ {/* Done button - from HEAD */}
+
+
+
+
+ );
+}
+
+export default Calendar;
diff --git a/src/components/CategoryCard.jsx b/src/components/CategoryCard.jsx
new file mode 100644
index 0000000..2ed9185
--- /dev/null
+++ b/src/components/CategoryCard.jsx
@@ -0,0 +1,189 @@
+import React, { useState } from "react";
+import {
+ Box,
+ Typography,
+ IconButton,
+ Avatar,
+ Chip,
+ TextField,
+} from "@mui/material";
+import CategoryIcon from "@mui/icons-material/Category";
+import { FiEdit2, FiTrash } from "react-icons/fi";
+import ConfirmDeleteModal from "@components/ConfirmDeleteModal";
+import { useTranslation } from 'react-i18next';
+
+const CategoryCard = ({ category, onUpdateCategory, onDeleteCategory }) => {
+ const [openDeleteModal, setOpenDeleteModal] = useState(false);
+ const [isEditing, setIsEditing] = useState(false);
+ const [editedName, setEditedName] = useState(category.name);
+ const { t } = useTranslation();
+
+ const handleEditToggle = () => setIsEditing(true);
+
+ const handleBlur = () => {
+ setIsEditing(false);
+ if (editedName.trim() !== "" && editedName !== category.name) {
+ onUpdateCategory({ ...category, name: editedName });
+ }
+ };
+
+ const handleDelete = () => {
+ setOpenDeleteModal(true);
+ };
+
+ const confirmDelete = () => {
+ onDeleteCategory(category.id);
+ setOpenDeleteModal(false);
+ };
+
+ return (
+ <>
+
+ {/* Delete Icon */}
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+ {isEditing ? (
+ setEditedName(e.target.value)}
+ onBlur={handleBlur}
+ autoFocus
+ InputProps={{
+ disableUnderline: true,
+ sx: {
+ padding: 0,
+ fontSize: "1rem",
+ fontWeight: "bold",
+ borderBottom: "2px solid #1976d2",
+ width: `${editedName.length + 1}ch`,
+ transition: "border 0.2s",
+ },
+ }}
+ />
+ ) : (
+
+ {category.name}
+
+
+
+
+ )}
+
+
+ {/* Label */}
+
+
+
+
+ {/* Decorative wave */}
+
+
+
+
+
+ {/* Confirm Delete Modal */}
+ setOpenDeleteModal(false)}
+ onConfirm={confirmDelete}
+ categoryName={category.name}
+ />
+ >
+ );
+};
+
+export default CategoryCard;
diff --git a/src/components/CategoryEditModal.jsx b/src/components/CategoryEditModal.jsx
new file mode 100644
index 0000000..16a7a4c
--- /dev/null
+++ b/src/components/CategoryEditModal.jsx
@@ -0,0 +1,54 @@
+import React, { useState, useEffect } from "react";
+import { Dialog, DialogActions, DialogContent, DialogTitle, TextField, Button } from "@mui/material";
+
+const CategoryEditModal = ({ open, onClose, category, onUpdateCategory }) => {
+ const [categoryName, setCategoryName] = useState(category.name);
+ const [categoryDescription, setCategoryDescription] = useState(category.description);
+
+ useEffect(() => {
+ if (category) {
+ setCategoryName(category.name);
+ setCategoryDescription(category.description);
+ }
+ }, [category]);
+
+ const handleSave = () => {
+ onUpdateCategory({ ...category, name: categoryName, description: categoryDescription });
+ onClose();
+ };
+
+ return (
+
+ );
+};
+
+export default CategoryEditModal;
diff --git a/src/components/CategoryTabs.jsx b/src/components/CategoryTabs.jsx
new file mode 100644
index 0000000..cf6cde6
--- /dev/null
+++ b/src/components/CategoryTabs.jsx
@@ -0,0 +1,76 @@
+import React from "react";
+import { Box, ToggleButton, ToggleButtonGroup } from "@mui/material";
+import { FaBoxOpen } from "react-icons/fa";
+import { FaStore } from "react-icons/fa";
+import { useTranslation } from 'react-i18next';
+
+const CategoryTabs = ({ selectedType, onChangeType }) => {
+ const { t } = useTranslation();
+ return (
+
+ value && onChangeType(value)}
+ sx={{
+ backgroundColor: "#fff",
+ borderRadius: 2,
+ p: 0.5,
+ gap: 1.5, // razmak između tabova
+ "& .MuiToggleButton-root": {
+ border: "none",
+ borderRadius: 2,
+ px: 3,
+ py: 1.2,
+ fontWeight: 600,
+ display: "flex",
+ alignItems: "center",
+ gap: 1,
+ fontSize: "0.95rem",
+ transition: "all 0.2s ease-in-out",
+ color: "#555",
+ "&:hover": {
+ backgroundColor: "#eaeaea",
+ },
+ "&.Mui-selected": {
+ color: "#fff",
+ "&:hover": {
+ opacity: 0.95,
+ },
+ },
+ },
+ }}
+ >
+
+
+ {t('common.productCategories')}
+
+
+
+
+ {t('common.storeCategories')}
+
+
+
+ );
+};
+
+export default CategoryTabs;
diff --git a/src/components/ChatHeader.jsx b/src/components/ChatHeader.jsx
new file mode 100644
index 0000000..92b1679
--- /dev/null
+++ b/src/components/ChatHeader.jsx
@@ -0,0 +1,29 @@
+// @components/ChatHeader.jsx
+import { Box, Typography, Stack, Chip } from '@mui/material';
+import CircleIcon from '@mui/icons-material/Circle';
+
+export default function ChatHeader({ username }) {
+ return (
+
+
+
+ {username || 'User'}
+
+
+
+ Online
+
+
+
+
+
+ );
+}
diff --git a/src/components/ChatInput.jsx b/src/components/ChatInput.jsx
new file mode 100644
index 0000000..dfa5f45
--- /dev/null
+++ b/src/components/ChatInput.jsx
@@ -0,0 +1,64 @@
+// @components/ChatInput.jsx
+import { useState } from 'react';
+import {
+ Box,
+ TextField,
+ IconButton,
+ Paper,
+ Switch,
+ FormControlLabel,
+} from '@mui/material';
+import SendIcon from '@mui/icons-material/Send';
+import AttachFileIcon from '@mui/icons-material/AttachFile';
+
+export default function ChatInput({ disabled, onSendMessage }) {
+ const [message, setMessage] = useState('');
+
+ const handleSend = () => {
+ if (message.trim() && !disabled) {
+ onSendMessage(message);
+ setMessage('');
+ }
+ };
+
+ const handleKeyPress = (e) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ handleSend();
+ }
+ };
+
+ return (
+
+
+
+ setMessage(e.target.value)}
+ onKeyPress={handleKeyPress}
+ sx={{ bgcolor: '#f7f8fa', borderRadius: 2 }}
+ disabled={disabled}
+ />
+
+
+
+
+
+ );
+}
diff --git a/src/components/ChatMessage.jsx b/src/components/ChatMessage.jsx
new file mode 100644
index 0000000..25355bd
--- /dev/null
+++ b/src/components/ChatMessage.jsx
@@ -0,0 +1,100 @@
+// @components/ChatMessage.jsx
+import { Box, Paper, Typography, Stack } from '@mui/material';
+
+export default function ChatMessage({
+ sender,
+ isAdmin,
+ text,
+ time,
+ attachment,
+ isPrivate = false,
+}) {
+ return (
+
+
+ {!isAdmin && (
+
+ {sender}
+
+ )}
+
+
+ {text}
+
+
+ {attachment && (
+
+
+
+ {attachment.name}
+
+
+ )}
+
+
+
+ {time}
+
+
+ {isPrivate && (
+
+ Private
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/ChatMessages.jsx b/src/components/ChatMessages.jsx
new file mode 100644
index 0000000..b06f271
--- /dev/null
+++ b/src/components/ChatMessages.jsx
@@ -0,0 +1,46 @@
+// @components/ChatMessages.jsx
+import { Box } from '@mui/material';
+import ChatMessage from './ChatMessage';
+import { useEffect, useRef } from 'react';
+
+export default function ChatMessages({ messages = [], userId }) {
+ const messagesEndRef = useRef(null);
+
+ // Scroll to bottom when messages change
+ useEffect(() => {
+ messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
+ }, [messages]);
+
+ return (
+
+ {messages.map((msg) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/ConfirmDeleteModal.jsx b/src/components/ConfirmDeleteModal.jsx
new file mode 100644
index 0000000..1098663
--- /dev/null
+++ b/src/components/ConfirmDeleteModal.jsx
@@ -0,0 +1,84 @@
+import React from "react";
+import {
+ Modal,
+ Box,
+ Typography,
+ Button,
+ Stack,
+ IconButton,
+} from "@mui/material";
+import { FiTrash2 } from "react-icons/fi";
+
+const ConfirmDeleteModal = ({ open, onClose, onConfirm, categoryName }) => {
+ return (
+
+
+
+
+
+
+
+ Are you sure?
+
+
+ You’re about to delete the category
+ {categoryName}. This action cannot be undone.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ConfirmDeleteModal;
diff --git a/src/components/ConfirmDeleteStoreModal.jsx b/src/components/ConfirmDeleteStoreModal.jsx
new file mode 100644
index 0000000..95d93a1
--- /dev/null
+++ b/src/components/ConfirmDeleteStoreModal.jsx
@@ -0,0 +1,83 @@
+import React from "react";
+import {
+ Modal,
+ Box,
+ Typography,
+ Button,
+ Stack,
+ IconButton,
+} from "@mui/material";
+import { FiTrash2 } from "react-icons/fi";
+
+const ConfirmDeleteStoreModal = ({ open, onClose, onConfirm, storeName }) => {
+ return (
+
+
+
+
+
+
+
+ Are you sure?
+
+
+ You’re about to delete the store {storeName}. This action cannot be undone.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ConfirmDeleteStoreModal;
diff --git a/src/components/ConfirmDialog.jsx b/src/components/ConfirmDialog.jsx
new file mode 100644
index 0000000..822f7f4
--- /dev/null
+++ b/src/components/ConfirmDialog.jsx
@@ -0,0 +1,31 @@
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Typography,
+ } from "@mui/material";
+ import { useTranslation } from 'react-i18next';
+
+ export default function ConfirmDialog({ open, onClose, onConfirm, message }) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+ }
+
\ No newline at end of file
diff --git a/src/components/CountryStatsPanel.jsx b/src/components/CountryStatsPanel.jsx
new file mode 100644
index 0000000..864bc58
--- /dev/null
+++ b/src/components/CountryStatsPanel.jsx
@@ -0,0 +1,209 @@
+import React, { useEffect, useState } from 'react';
+import {
+ Card,
+ CardContent,
+ Box,
+ Typography,
+ Tabs,
+ Tab,
+ LinearProgress,
+} from '@mui/material';
+import Flag from 'react-world-flags';
+import {
+ apiGetAllStoresAsync,
+ apiFetchOrdersAsync,
+ apiGetGeographyAsync,
+} from '../api/api.js';
+import { useTranslation } from 'react-i18next';
+
+const CountryStatsPanel = () => {
+ const { t } = useTranslation();
+ const [tab, setTab] = useState(0);
+ const [data, setData] = useState({ revenue: [], orders: [] });
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const [stores, orders, geography] = await Promise.all([
+ apiGetAllStoresAsync(),
+ apiFetchOrdersAsync(),
+ apiGetGeographyAsync(),
+ ]);
+
+ const { regions, places } = geography;
+
+ const regionMap = {};
+ regions.forEach((r) => {
+ regionMap[r.id] = {
+ name: r.name,
+ code: r.countryCode?.toUpperCase() || 'BA',
+ };
+ });
+
+ const placeNameToRegionId = {};
+ places.forEach((p) => {
+ placeNameToRegionId[p.name] = p.regionId;
+ });
+
+ const storeMap = {};
+ stores.forEach((store) => {
+ storeMap[store.id] = store;
+ });
+
+ const revenueByRegion = {};
+ let totalRevenue = 0;
+
+ const ordersByRegion = {};
+ let totalOrders = 0;
+
+ orders.forEach((order) => {
+ const store = storeMap[order.storeName];
+ if (!store) return;
+
+ const regionId = placeNameToRegionId[store.placeName];
+ const region = regionMap[regionId];
+
+ const targetId = region ? regionId : 'others';
+ const targetRegion = region || { name: 'Others', code: 'BA' };
+
+ if (!revenueByRegion[targetId]) {
+ revenueByRegion[targetId] = {
+ name: targetRegion.name,
+ code: targetRegion.code,
+ value: 0,
+ count: 0,
+ };
+ }
+ revenueByRegion[targetId].value += order.totalPrice || 0;
+ revenueByRegion[targetId].count += 1;
+ totalRevenue += order.totalPrice || 0;
+
+ if (!ordersByRegion[targetId]) {
+ ordersByRegion[targetId] = {
+ name: targetRegion.name,
+ code: targetRegion.code,
+ value: 0,
+ };
+ }
+ ordersByRegion[targetId].value += 1;
+ totalOrders += 1;
+ });
+
+ const revenueSorted = Object.values(revenueByRegion).sort(
+ (a, b) => b.value - a.value
+ );
+ const ordersSorted = Object.values(ordersByRegion).sort(
+ (a, b) => b.value - a.value
+ );
+
+ const topRevenue = revenueSorted.slice(0, 4);
+ const otherRevenue = revenueSorted.slice(4).reduce(
+ (acc, r) => {
+ acc.value += r.value;
+ acc.count += r.count;
+ return acc;
+ },
+ { name: 'Others', code: 'BA', value: 0, count: 0 }
+ );
+ const revenueArr = [...topRevenue];
+ if (otherRevenue.value > 0) {
+ revenueArr.push({
+ ...otherRevenue,
+ percent: Number(
+ ((otherRevenue.value / totalRevenue) * 100).toFixed(1)
+ ),
+ });
+ }
+ revenueArr.forEach((r) => {
+ r.percent = Number(((r.value / totalRevenue) * 100).toFixed(1));
+ });
+
+ const topOrders = ordersSorted.slice(0, 4);
+ const otherOrders = ordersSorted.slice(4).reduce(
+ (acc, o) => {
+ acc.value += o.value;
+ return acc;
+ },
+ { name: 'Others', code: 'BA', value: 0 }
+ );
+ const ordersArr = [...topOrders];
+ if (otherOrders.value > 0) {
+ ordersArr.push({
+ ...otherOrders,
+ percent: Number(((otherOrders.value / totalOrders) * 100).toFixed(1)),
+ });
+ }
+ ordersArr.forEach((o) => {
+ o.percent = Number(((o.value / totalOrders) * 100).toFixed(1));
+ });
+
+ setData({ revenue: revenueArr, orders: ordersArr });
+ };
+
+ fetchData();
+ }, []);
+
+ const labels = [t('analytics.ordersRevenueByRegions'), t('analytics.ordersByRegions')];
+ const keys = ['revenue', 'orders'];
+ const currentData = data[keys[tab]] || [];
+
+ return (
+
+
+
+ {labels[tab]}
+
+ setTab(newVal)}
+ size='small'
+ textColor='primary'
+ indicatorColor='primary'
+ sx={{ mb: 2 }}
+ >
+
+
+
+
+ {currentData.map((item, index) => (
+
+
+
+
+
+ {item.name}
+
+
+
+ {item.value.toLocaleString(undefined, {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}{' '}
+ • {item.percent}%
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default CountryStatsPanel;
diff --git a/src/components/CreateRouteModal.jsx b/src/components/CreateRouteModal.jsx
new file mode 100644
index 0000000..e01364e
--- /dev/null
+++ b/src/components/CreateRouteModal.jsx
@@ -0,0 +1,264 @@
+import React, { useState, useEffect } from 'react';
+import { Search, CheckSquare, Square } from 'lucide-react';
+import {
+ Modal,
+ Box,
+ Typography,
+ TextField,
+ Button,
+ InputAdornment,
+ Chip,
+} from '@mui/material';
+import LocalShippingIcon from '@mui/icons-material/LocalShipping';
+import sha256 from 'crypto-js/sha256';
+import {
+ apiFetchOrdersAsync,
+ createRouteAsync,
+ fetchAdressesAsync,
+ fetchAdressByIdAsync,
+ getGoogle,
+} from '@api/api';
+import {
+ apiCreateRouteAsync,
+ apiExternGetOptimalRouteAsync,
+ apiGetOrderAddresses,
+ apiGetStoreByIdAsync,
+} from '../api/api';
+
+const CreateRouteModal = ({ open, onClose, onCreateRoute }) => {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [selectedOrders, setSelectedOrders] = useState([]);
+ const [allOrders, setAllOrders] = useState([]);
+ const [loading, setLoading] = useState(false);
+ const [showSearchResults, setShowSearchResults] = useState(false);
+
+ useEffect(() => {
+ const fetchOrders = async () => {
+ try {
+ const fetched = await apiFetchOrdersAsync();
+ const addresses = await fetchAdressesAsync(); // all addresses
+
+ const enrichedOrders = await Promise.all(
+ fetched.map(async (order) => {
+ const store = await apiGetStoreByIdAsync(order.storeName); // fetch per order
+ const buyerAddress = await fetchAdressByIdAsync(order.addressId);
+ console.log(buyerAddress);
+ return {
+ ...order,
+ senderAddress: store.address,
+ buyerAddress: buyerAddress.address,
+ };
+ })
+ );
+
+ setAllOrders(enrichedOrders);
+ } catch (err) {
+ console.error('Greška prilikom učitavanja narudžbi:', err);
+ }
+ };
+
+ if (open) {
+ fetchOrders();
+ }
+ }, [open]);
+
+ const handleToggleOrder = (order) => {
+ const exists = selectedOrders.some((o) => o.id === order.id);
+ if (exists) {
+ setSelectedOrders(selectedOrders.filter((o) => o.id !== order.id));
+ } else {
+ setSelectedOrders([...selectedOrders, order]);
+ }
+ };
+
+ const filteredOrders = allOrders.filter((order) =>
+ order.id.toString().includes(searchTerm.trim())
+ );
+
+ const handleCreateRoute = async () => {
+ try {
+ setLoading(true);
+ onCreateRoute(selectedOrders);
+ onClose();
+ } catch (err) {
+ console.error('Greška pri kreiranju rute:', err);
+ alert('Došlo je do greške.');
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Create Route
+
+
+
+ {
+ setSearchTerm(e.target.value);
+ setShowSearchResults(true);
+ }}
+ onFocus={() => setShowSearchResults(true)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ sx: { borderRadius: 1 },
+ }}
+ sx={{ mb: 2 }}
+ />
+
+ {showSearchResults && searchTerm && (
+
+ {filteredOrders.map((order) => (
+ handleToggleOrder(order)}
+ sx={{
+ p: 2,
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer',
+ '&:hover': { bgcolor: 'action.hover' },
+ borderBottom: 1,
+ borderColor: 'divider',
+ }}
+ >
+ {selectedOrders.some((o) => o.id === order.id) ? (
+
+ ) : (
+
+ )}
+
+ Order #{order.id}
+
+ {`${order.senderAddress?.toString() || '?'} - ${order.buyerAddress?.toString() || '?'}`}
+
+
+
+ ))}
+
+ )}
+
+
+
+ Selected Orders
+
+
+
+ {selectedOrders.length === 0 ? (
+
+
+ No orders selected. Search to add orders.
+
+
+ ) : (
+ selectedOrders.map((order) => (
+ handleToggleOrder(order)}
+ sx={{
+ p: 2,
+ display: 'flex',
+ alignItems: 'center',
+ cursor: 'pointer',
+ '&:hover': { bgcolor: 'action.hover' },
+ borderBottom: 1,
+ borderColor: 'divider',
+ }}
+ >
+
+
+ Order #{order.id}
+
+ {`${order.senderAddress?.toString() || '?'} - ${order.buyerAddress?.toString() || '?'}`}
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CreateRouteModal;
diff --git a/src/components/CustomButton.jsx b/src/components/CustomButton.jsx
new file mode 100644
index 0000000..674e68b
--- /dev/null
+++ b/src/components/CustomButton.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import Button from '@mui/material/Button';
+import buttonStyle from './CustomButtonStyles';
+
+const CustomButton = ({ children, ...props }) => {
+ return (
+
+ );
+};
+
+export default CustomButton;
diff --git a/src/components/CustomButtonStyles.jsx b/src/components/CustomButtonStyles.jsx
new file mode 100644
index 0000000..bd2b102
--- /dev/null
+++ b/src/components/CustomButtonStyles.jsx
@@ -0,0 +1,25 @@
+const buttonStyle = (theme) => ({
+ textTransform: 'none',
+ borderRadius: '12px',
+ paddingY: 1.2,
+ paddingX: 4,
+ fontWeight: 600,
+ fontSize: '1rem',
+ backgroundColor: theme.palette.text.primary,
+ color: theme.palette.primary.contrastText,
+ boxShadow: '0 4px 10px rgba(0,0,0,0.1)',
+ transition: 'all 0.3s ease-in-out',
+
+ '&:hover': {
+ backgroundColor: '#3b0f0f',
+ boxShadow: '0 6px 14px rgba(0,0,0,0.2)',
+ },
+
+ '&:focus': {
+ outline: 'none',
+ boxShadow: `0 0 0 3px rgba(77, 18, 17, 0.3)`,
+ },
+ });
+
+ export default buttonStyle;
+
\ No newline at end of file
diff --git a/src/components/CustomTextField.jsx b/src/components/CustomTextField.jsx
new file mode 100644
index 0000000..db5fec9
--- /dev/null
+++ b/src/components/CustomTextField.jsx
@@ -0,0 +1,41 @@
+import React, { useState } from 'react';
+import { TextField, InputAdornment, IconButton } from '@mui/material';
+import { HiOutlineMail, HiOutlineLockClosed, HiOutlineEye, HiOutlineEyeOff } from 'react-icons/hi';
+
+
+const CustomTextField = ({ label, type, ...props }) => {
+ const [showPassword, setShowPassword] = useState(false);
+ const isPassword = type === 'password';
+
+ return (
+
+ {label.toLowerCase().includes('email') ? (
+
+ ) : isPassword ? (
+
+ ) : null}
+
+ ),
+ endAdornment: isPassword && (
+
+ setShowPassword((prev) => !prev)}>
+ {showPassword ? : }
+
+
+ ),
+ },
+ }}
+ />
+ );
+};
+
+export default CustomTextField;
diff --git a/src/components/CustomTextFieldStyles.jsx b/src/components/CustomTextFieldStyles.jsx
new file mode 100644
index 0000000..023c40c
--- /dev/null
+++ b/src/components/CustomTextFieldStyles.jsx
@@ -0,0 +1,17 @@
+const textFieldStyle = {
+ marginBottom: 2,
+ '& .MuiOutlinedInput-root': {
+ borderRadius: '12px',
+ '& fieldset': {
+ borderWidth: '2px',
+ },
+ '&:hover fieldset': {
+ borderColor: '#3C5B66',
+ },
+ '&.Mui-focused fieldset': {
+ borderColor: '#3C5B66',
+ },
+ },
+};
+
+export default textFieldStyle;
diff --git a/src/components/DealsChart.jsx b/src/components/DealsChart.jsx
new file mode 100644
index 0000000..78895c2
--- /dev/null
+++ b/src/components/DealsChart.jsx
@@ -0,0 +1,356 @@
+import React, { useState, useRef, useEffect } from 'react'; // Merged: useEffect from develop
+import {
+ Box,
+ Typography,
+ IconButton,
+ Menu,
+ MenuItem,
+ Paper, // Ensured Paper is present
+} from '@mui/material';
+import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
+import FilterListIcon from '@mui/icons-material/FilterList';
+import StoreIcon from '@mui/icons-material/Store';
+import { Bar } from 'react-chartjs-2';
+import {
+ Chart as ChartJS,
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ Tooltip as ChartTooltip,
+ Legend,
+} from 'chart.js';
+import { apiGetAllStoresAsync, apiGetAllAdsAsync } from '../api/api.js'; // From develop
+import { useTranslation } from 'react-i18next';
+
+
+ChartJS.register(
+ CategoryScale,
+ LinearScale,
+ BarElement,
+ Title,
+ ChartTooltip,
+ Legend
+);
+
+function DealsChart() {
+ const { t } = useTranslation();
+ const [filterType, setFilterType] = useState('topRated'); // 'topRated' or 'lowestRated'
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [storesData, setStoresData] = useState({
+ topRated: [],
+ lowestRated: [],
+ }); // From develop, initialized
+ const [barPositions, setBarPositions] = useState([]); // From develop
+ const open = Boolean(anchorEl);
+ const chartRef = useRef(null);
+ const containerRef = useRef(null); // From develop
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ const [storesResponse, adsResponse] = await Promise.all([
+ apiGetAllStoresAsync(),
+ apiGetAllAdsAsync(),
+ ]);
+
+ const stores = Array.isArray(storesResponse) ? storesResponse : [];
+ const ads =
+ adsResponse && Array.isArray(adsResponse.data)
+ ? adsResponse.data
+ : [];
+
+ const storeMap = {};
+ stores.forEach((store) => {
+ if (store && store.id) {
+ // Ensure store and store.id exist
+ storeMap[store.id] = store.name || `Store ${store.id}`; // Use name or fallback
+ }
+ });
+
+ const revenueByStore = {};
+ ads.forEach((ad) => {
+ if (!ad || !ad.conversionPrice || ad.conversionPrice === 0) return;
+ if (ad.adData && Array.isArray(ad.adData)) {
+ ad.adData.forEach((adDataItem) => {
+ if (!adDataItem || !adDataItem.storeId) return; // Ensure adDataItem and storeId exist
+ const storeId = adDataItem.storeId;
+ if (!storeMap[storeId]) return;
+ const revenue = (ad.conversionPrice || 0) * (ad.conversions || 0);
+ revenueByStore[storeId] =
+ (revenueByStore[storeId] || 0) + revenue;
+ });
+ }
+ });
+
+ const sortedStoresData = Object.entries(revenueByStore)
+ .map(([storeId, amount]) => ({
+ id: storeId,
+ name: storeMap[storeId] || `Store ${storeId}`, // Fallback name
+ amount,
+ }))
+ .sort((a, b) => b.amount - a.amount); // Sort descending by amount
+
+ const topRated = sortedStoresData.slice(0, 5);
+ // For lowest rated, take the last 5 (smallest amounts) and then sort them ascending for display
+ const lowestRated = sortedStoresData
+ .slice(-5)
+ .sort((a, b) => a.amount - b.amount);
+
+ setStoresData({
+ topRated,
+ lowestRated,
+ });
+ } catch (error) {
+ console.error('Failed to fetch deals data:', error);
+ setStoresData({ topRated: [], lowestRated: [] }); // Reset on error
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ const handleFilterClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const handleFilterChange = (type) => {
+ setFilterType(type);
+ handleClose();
+ setBarPositions([]); // Reset bar positions when filter changes
+ };
+
+ const currentDisplayData = storesData[filterType] || [];
+
+ const chartData = {
+ labels: currentDisplayData.map((item) => item.name), // Use store names for labels
+ datasets: [
+ {
+ data: currentDisplayData.map((item) => item.amount),
+ backgroundColor: '#353535', // Darker bars from develop
+ borderWidth: 1,
+ borderColor: '#000',
+ borderRadius: 24, // More rounded bars from develop
+ barThickness: 55, // Bar thickness from develop
+ },
+ ],
+ };
+
+ const chartOptions = {
+ responsive: true,
+ maintainAspectRatio: false,
+ plugins: {
+ legend: { display: false },
+ tooltip: {
+ enabled: true,
+ callbacks: {
+ label: function (context) {
+ return `$${context.raw.toLocaleString()}`;
+ },
+ title: function (context) {
+ // Tooltip title from develop
+ return context && context[0] ? context[0].label : '';
+ },
+ },
+ },
+ },
+ scales: {
+ x: {
+ display: false, // Hiding x-axis labels as info is on icons/tooltips
+ grid: { display: false },
+ },
+ y: {
+ display: false, // Hiding y-axis
+ grid: { display: false },
+ beginAtZero: true, // Important for bar charts
+ },
+ },
+ animation: {
+ // For calculating icon positions from develop
+ onComplete: function () {
+ if (chartRef.current) {
+ const chart = chartRef.current;
+ const meta = chart.getDatasetMeta(0);
+ const newPositions = [];
+
+ if (meta && meta.data && meta.data.length > 0) {
+ meta.data.forEach((bar) => {
+ newPositions.push({
+ top: bar.y, // y-coordinate of the top of the bar
+ left: bar.x, // x-coordinate of the center of the bar
+ // width: bar.width, // not strictly needed for icon positioning here
+ });
+ });
+ // Only update if positions actually changed to prevent potential loops
+ if (
+ newPositions.length !== barPositions.length ||
+ newPositions.some(
+ (p, i) =>
+ p.top !== barPositions[i]?.top ||
+ p.left !== barPositions[i]?.left
+ )
+ ) {
+ setBarPositions(newPositions);
+ }
+ } else if (barPositions.length > 0) {
+ // If no data, clear positions
+ setBarPositions([]);
+ }
+ }
+ },
+ duration: 300, // Give a small duration for animation to complete
+ },
+ layout: {
+ // Padding from HEAD, adjusted
+ padding: { top: 30, bottom: 10, left: 10, right: 10 },
+ },
+ };
+
+ const chartHeight = 250;
+
+ return (
+
+
+
+
+ Filters
+
+
+
+
+
+
+
+
+
+ {/* Icons on top of bars - logic from develop */}
+ {currentDisplayData.length > 0 &&
+ barPositions.length === currentDisplayData.length &&
+ barPositions.map((pos, index) => {
+ const item = currentDisplayData[index];
+ if (!item) return null;
+
+ let iconBackgroundColor;
+ if (filterType === 'topRated') {
+ if (index === 0)
+ iconBackgroundColor = '#FFD700'; // Gold
+ else if (index === 1)
+ iconBackgroundColor = '#C0C0C0'; // Silver
+ else if (index === 2)
+ iconBackgroundColor = '#CD7F32'; // Bronze
+ else iconBackgroundColor = '#B4D4C3'; // Neutral
+ } else {
+ // Lowest Rated (currentDisplayData is sorted ascending for 'lowestRated')
+ if (index === 0)
+ iconBackgroundColor = '#f44336'; // Lowest
+ else if (index === 1)
+ iconBackgroundColor = '#E57373'; // 2nd Lowest
+ else if (index === 2)
+ iconBackgroundColor = '#FFB74D'; // 3rd Lowest
+ else iconBackgroundColor = '#B4D4C3'; // Neutral
+ }
+
+ return (
+
+
+
+ );
+ })}
+
+
+ {/* Text at the bottom - "by store" from develop */}
+
+
+ {t('analytics.dealsAmount')}
+
+
+
+ by store
+
+
+
+
+
+ );
+}
+
+export default DealsChart;
diff --git a/src/components/DeleteAdConfirmation.jsx b/src/components/DeleteAdConfirmation.jsx
new file mode 100644
index 0000000..fe01565
--- /dev/null
+++ b/src/components/DeleteAdConfirmation.jsx
@@ -0,0 +1,84 @@
+import React from "react";
+import {
+ Modal,
+ Box,
+ Typography,
+ Button,
+ Stack,
+ IconButton,
+} from "@mui/material";
+import { Trash2 } from "lucide-react";
+
+const DeleteConfirmationModal = ({ open, onClose, onConfirm }) => {
+ return (
+
+
+
+
+
+
+
+ Are you sure?
+
+
+ You’re about to delete this ad. This action cannot be undone.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DeleteConfirmationModal;
+
\ No newline at end of file
diff --git a/src/components/DeleteConfirmModal.jsx b/src/components/DeleteConfirmModal.jsx
new file mode 100644
index 0000000..3057695
--- /dev/null
+++ b/src/components/DeleteConfirmModal.jsx
@@ -0,0 +1,43 @@
+// @components/DeleteConfirmModal.jsx
+import React from 'react';
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Button,
+ Typography,
+} from '@mui/material';
+import DeleteIcon from '@mui/icons-material/Delete';
+import { useTranslation } from 'react-i18next';
+
+export default function DeleteConfirmModal({
+ open,
+ onClose,
+ onConfirm,
+ ticketTitle,
+}) {
+ const { t } = useTranslation();
+
+ return (
+
+ );
+}
diff --git a/src/components/DeleteRouteConfirmation.jsx b/src/components/DeleteRouteConfirmation.jsx
new file mode 100644
index 0000000..11b210e
--- /dev/null
+++ b/src/components/DeleteRouteConfirmation.jsx
@@ -0,0 +1,84 @@
+import React from "react";
+import {
+ Modal,
+ Box,
+ Typography,
+ Button,
+ Stack,
+ IconButton,
+} from "@mui/material";
+import { Trash2 } from "lucide-react";
+
+const DeleteConfirmationModal = ({ open, onClose, onConfirm }) => {
+ return (
+
+
+
+
+
+
+
+ Are you sure?
+
+
+ You’re about to delete this route. This action cannot be undone.
+
+
+
+
+
+
+
+
+ );
+};
+
+export default DeleteConfirmationModal;
+
\ No newline at end of file
diff --git a/src/components/DeleteUserButton.jsx b/src/components/DeleteUserButton.jsx
new file mode 100644
index 0000000..4e1ed4d
--- /dev/null
+++ b/src/components/DeleteUserButton.jsx
@@ -0,0 +1,10 @@
+import { IconButton } from "@mui/material";
+import DeleteIcon from "@mui/icons-material/Delete";
+
+export default function DeleteUserButton({ onClick }) {
+ return (
+
+
+
+ );
+}
diff --git a/src/components/EditAdModal.jsx b/src/components/EditAdModal.jsx
new file mode 100644
index 0000000..e2f369a
--- /dev/null
+++ b/src/components/EditAdModal.jsx
@@ -0,0 +1,286 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Modal,
+ Box,
+ Typography,
+ TextField,
+ Checkbox,
+ Button,
+ IconButton,
+ Stack,
+ MenuItem,
+ Autocomplete,
+} from '@mui/material';
+import { Edit3, Trash2 } from 'lucide-react';
+import {
+ apiRemoveAdItemAsync,
+ apiGetStoreProductsAsync,
+} from '../api/api';
+
+const EditAdModal = ({ open, ad, stores, onClose, onSave }) => {
+ const [startTime, setStartTime] = useState('');
+ const [endTime, setEndTime] = useState('');
+ const [isActive, setIsActive] = useState(false);
+ const [adType, setAdType] = useState('');
+ const [triggers, setTriggers] = useState([]);
+ const [adContentItems, setAdContentItems] = useState([]);
+ const [products, setProducts] = useState([]);
+
+ useEffect(() => {
+ console.log(ad);
+ if (ad) {
+ setStartTime(ad.startTime || '');
+ setEndTime(ad.endTime || '');
+ setIsActive(ad.isActive || false);
+ setAdType(ad.adType || '');
+ setTriggers(ad.triggers);
+ setProducts([]);
+ setAdContentItems(
+ (ad.adData || []).map((item) => ({
+ ...item,
+ imageFile: null, // novo uploadovan file (ako bude)
+ existingImageUrl: item.imageUrl, // postojeca slika iz GET-a
+ }))
+ );
+ }
+ }, [ad]);
+
+ const handleFieldChange = async (index, field, value) => {
+ const updatedItems = [...adContentItems];
+ updatedItems[index][field] = value;
+ setAdContentItems(updatedItems);
+
+ if (field == "storeId") {
+ const products = await apiGetStoreProductsAsync(value);
+ setProducts(products.data);
+ }
+ };
+
+ const handleFileChange = (index, file) => {
+ const updatedItems = [...adContentItems];
+ updatedItems[index].imageFile = file;
+ setAdContentItems(updatedItems);
+ };
+
+ const handleRemoveItem = async (index) => {
+ const updatedItems = [...adContentItems];
+ updatedItems.splice(index, 1);
+
+ const id = ad.adData[index].id;
+ const res = await apiRemoveAdItemAsync(id);
+
+ setAdContentItems(updatedItems);
+ };
+
+ const handleSave = () => {
+ const cleanedItems = adContentItems.map((item) => ({
+ storeId: Number(item.storeId),
+ productId: Number(item.productId),
+ description: item.description,
+ imageFile: item.imageFile || null, // ako nema novog file-a, backend koristi stari
+ }));
+
+ onSave?.(ad.id, {
+ startTime,
+ endTime,
+ isActive,
+ adType,
+ triggers,
+ newAdDataItems: cleanedItems,
+ });
+
+ onClose();
+ };
+
+ return (
+
+
+
+
+
+ Edit Advertisement
+
+
+
+ {/* Time + Active */}
+
+ setStartTime(e.target.value)}
+ InputLabelProps={{ shrink: true }}
+ />
+ setEndTime(e.target.value)}
+ InputLabelProps={{ shrink: true }}
+ />
+
+ setIsActive(e.target.checked)}
+ sx={{ mr: 1 }}
+ />
+ Is Active
+
+ setAdType(e.target.value)}
+ InputLabelProps={{ shrink: true }}
+ >
+
+
+
+ setTriggers(newValue)}
+ disableCloseOnSelect
+ getOptionLabel={(option) => option}
+ renderOption={(props, option, { selected }) => (
+
+
+ {option}
+
+ )}
+ style={{ width: '100%', marginBottom: 16 }}
+ renderInput={(params) => (
+
+ )}
+ />
+
+
+ {/* Ad Items Section */}
+
+ Advertisement Items
+
+
+
+
+ {adContentItems.map((item, index) => (
+
+
+ handleFieldChange(index, 'description', e.target.value)
+ }
+ sx={{ mb: 1 }}
+ />
+ {
+ handleFieldChange(index, 'storeId', e.target.value);
+ }
+ }
+ sx={{ mb: 1 }}
+ >
+ {stores.map((store) => (
+
+ ))}
+
+ {
+ handleFieldChange(index, 'productId', e.target.value);
+ }
+ }
+ sx={{ mb: 1 }}
+ >
+ {products.map((product) => (
+
+ ))}
+
+
+ handleRemoveItem(index)}
+ size='small'
+ >
+
+
+
+ ))}
+
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+ );
+};
+
+export default EditAdModal;
diff --git a/src/components/EditProductModal.jsx b/src/components/EditProductModal.jsx
new file mode 100644
index 0000000..5cdaf5e
--- /dev/null
+++ b/src/components/EditProductModal.jsx
@@ -0,0 +1,215 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Modal,
+ Box,
+ TextField,
+ Button,
+ Typography,
+ MenuItem,
+} from '@mui/material';
+import { HiOutlineCube } from 'react-icons/hi';
+import { apiUpdateProductAsync, apiGetProductCategoriesAsync } from '@api/api';
+
+const weightUnits = ['kg', 'g', 'lbs'];
+const volumeUnits = ['L', 'ml', 'oz'];
+
+const EditProductModal = ({ open, onClose, product, onSave }) => {
+ const [formData, setFormData] = useState({
+ name: '',
+ retailPrice: '',
+ weight: '',
+ weightUnit: 'kg',
+ volume: '',
+ volumeUnit: 'L',
+ productCategoryId: '',
+ isActive: true,
+ });
+
+ const [productCategories, setProductCategories] = useState([]);
+
+ useEffect(() => {
+ if (open) {
+ apiGetProductCategoriesAsync().then(setProductCategories);
+ if (product) {
+ setFormData({
+ name: product.name || '',
+ retailPrice: product.retailPrice || '',
+ weight: product.weight || '',
+ weightUnit: product.weightUnit || 'kg',
+ volume: product.volume || '',
+ volumeUnit: product.volumeUnit || 'L',
+ productCategoryId: product.productCategoryId || '',
+ isActive: product.isActive ?? true,
+ });
+ }
+ }
+ }, [open, product]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+ setFormData((prev) => ({
+ ...prev,
+ [name]: name === 'isActive' ? JSON.parse(value) : value,
+ }));
+ };
+
+ const handleSubmit = async () => {
+ try {
+ const response = await apiUpdateProductAsync({
+ id: product.id,
+ storeId: product.storeId,
+ photos: product.photos,
+ ...formData,
+ });
+ if (response.status >= 200 && response.status < 300) {
+ onSave(response.data || {});
+ onClose();
+ window.location.reload();
+ }
+ } catch (error) {
+ console.error('Error updating product:', error);
+ }
+ };
+
+ return (
+
+
+
+
+
+ Edit Product
+
+
+
+
+
+
+
+
+
+
+ {weightUnits.map((unit) => (
+
+ ))}
+
+
+
+
+
+
+ {volumeUnits.map((unit) => (
+
+ ))}
+
+
+
+
+ {productCategories.map((cat) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default EditProductModal;
diff --git a/src/components/EditStoreModal.jsx b/src/components/EditStoreModal.jsx
new file mode 100644
index 0000000..ddf5ce1
--- /dev/null
+++ b/src/components/EditStoreModal.jsx
@@ -0,0 +1,181 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Modal,
+ Box,
+ TextField,
+ Button,
+ MenuItem,
+ Select,
+ InputLabel,
+ FormControl,
+ Typography,
+ Avatar,
+} from '@mui/material';
+import EditIcon from '@mui/icons-material/Edit';
+import { apiGetStoreCategoriesAsync, apiUpdateStoreAsync } from '../api/api';
+
+const StoreEditModal = ({ open, onClose, store, onStoreUpdated }) => {
+ const [storeName, setStoreName] = useState('');
+ const [categoryId, setCategoryId] = useState('');
+ const [description, setDescription] = useState('');
+ const [address, setAddress] = useState('');
+ const [tax, setTax] = useState('');
+ const [categories, setCategories] = useState([]);
+ const [loading, setLoading] = useState(false);
+
+ useEffect(() => {
+ if (open) {
+ apiGetStoreCategoriesAsync().then(setCategories);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (store) {
+ setStoreName(store.name || '');
+ setCategoryId(store.categoryId || '');
+ setDescription(store.description || '');
+ setAddress(store.address || '');
+ setTax(store.tax?.toString() || '');
+ }
+ }, [store]);
+
+ const handleSubmit = async (e) => {
+ e.preventDefault();
+ setLoading(true);
+
+ const updatedData = {
+ id: store.id,
+ name: storeName,
+ address,
+ categoryId,
+ description,
+ tax: parseFloat(tax)/100,
+ isActive: store.isOnline ?? true,
+ };
+
+ await onStoreUpdated(updatedData);
+ onClose();
+ setLoading(false);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Edit Store
+
+
+
+
+
+ );
+};
+
+export default StoreEditModal;
diff --git a/src/components/EditStoreModalStyle.jsx b/src/components/EditStoreModalStyle.jsx
new file mode 100644
index 0000000..449ca8d
--- /dev/null
+++ b/src/components/EditStoreModalStyle.jsx
@@ -0,0 +1,20 @@
+const styles = {
+ modalBox: {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ backgroundColor: 'white',
+ padding: 3,
+ minWidth: 400,
+ boxShadow: 24,
+ borderRadius: 2,
+ },
+ buttonsContainer: {
+ display: 'flex',
+ justifyContent: 'space-between',
+ marginTop: 2,
+ },
+ };
+
+ export default styles;
\ No newline at end of file
diff --git a/src/components/FunnelCurved.jsx b/src/components/FunnelCurved.jsx
new file mode 100644
index 0000000..bb7a6fc
--- /dev/null
+++ b/src/components/FunnelCurved.jsx
@@ -0,0 +1,85 @@
+import React from 'react';
+
+function getWidth(percent, maxWidth) {
+ return (percent / 100) * maxWidth;
+}
+
+const FunnelCurved = ({ steps, width = 700, height = 200 }) => {
+ const stepHeight = height / steps.length;
+ const maxWidth = width;
+
+ return (
+
+ );
+};
+
+export default FunnelCurved;
diff --git a/src/components/HorizontalScroll.jsx b/src/components/HorizontalScroll.jsx
new file mode 100644
index 0000000..8675291
--- /dev/null
+++ b/src/components/HorizontalScroll.jsx
@@ -0,0 +1,61 @@
+import { Box, IconButton } from '@mui/material';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { useRef } from 'react';
+
+const HorizontalScroll = ({ children }) => {
+ const scrollRef = useRef();
+
+ const scroll = (offset) => {
+ scrollRef.current.scrollBy({ left: offset, behavior: 'smooth' });
+ };
+
+ return (
+
+ scroll(-600)}
+ sx={{
+ position: 'absolute',
+ top: '50%',
+ left: -20,
+ transform: 'translateY(-50%)',
+ zIndex: 1,
+ backgroundColor: '#fff',
+ boxShadow: 1,
+ }}
+ >
+
+
+
+
+ {children}
+
+
+ scroll(600)}
+ sx={{
+ position: 'absolute',
+ top: '50%',
+ right: -20,
+ transform: 'translateY(-50%)',
+ zIndex: 1,
+ backgroundColor: '#fff',
+ boxShadow: 1,
+ }}
+ >
+
+
+
+ );
+};
+
+export default HorizontalScroll;
diff --git a/src/components/ImageUploader.jsx b/src/components/ImageUploader.jsx
new file mode 100644
index 0000000..125c2d2
--- /dev/null
+++ b/src/components/ImageUploader.jsx
@@ -0,0 +1,178 @@
+import React, { useCallback, useState } from 'react';
+import { useDropzone } from 'react-dropzone';
+import {
+ Box,
+ Typography,
+ Button,
+ LinearProgress,
+ IconButton,
+} from '@mui/material';
+import { CloudUpload, Cancel } from '@mui/icons-material';
+import { useTranslation } from 'react-i18next';
+
+const MAX_SIZE_MB = 50;
+
+const ImageUploader = ({ onFilesSelected }) => {
+ const [files, setFiles] = useState([]);
+ const { t } = useTranslation();
+
+ const onDrop = useCallback(
+ (acceptedFiles) => {
+ setFiles((prev) => [
+ ...prev,
+ ...acceptedFiles.map((file) => ({
+ name: file.name,
+ size: file.size,
+ status: file.size > MAX_SIZE_MB * 1024 * 1024 ? 'error' : 'success',
+ })),
+ ]);
+
+ onFilesSelected(acceptedFiles);
+ },
+ [onFilesSelected]
+ );
+
+ const { getRootProps, getInputProps, isDragActive } = useDropzone({
+ onDrop,
+ accept: {
+ 'image/*': [],
+ },
+ multiple: true,
+ maxSize: MAX_SIZE_MB * 1024 * 1024,
+ });
+
+ const formatSize = (bytes) => `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
+
+ const removeFile = (name) => {
+ setFiles((prev) => prev.filter((f) => f.name !== name));
+ };
+
+ return (
+
+ {/* Dropzone */}
+
+
+
+
+ {t('common.dragFilesToUpload')}
+
+
+ {t('common.or')}
+
+
+
+ {t('common.maxFileSize')}
+
+
+
+ {/* File Preview List */}
+
+ {files.map((f, i) => (
+
+ removeFile(f.name)}
+ sx={{ position: 'absolute', top: 4, right: 4 }}
+ >
+
+
+
+
+
+
+ {f.name}
+
+
+
+
+ {formatSize(f.size)}
+
+
+
+
+ {f.status === 'error' && (
+
+ File too large
+
+ )}
+
+ ))}
+
+
+ );
+};
+
+export default ImageUploader;
diff --git a/src/components/KpiCard.jsx b/src/components/KpiCard.jsx
new file mode 100644
index 0000000..a377f05
--- /dev/null
+++ b/src/components/KpiCard.jsx
@@ -0,0 +1,92 @@
+import { Card, CardContent, Box, Typography } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import {
+ PackageOpen,
+ Users,
+ Store,
+ Boxes,
+ DollarSign,
+ CheckCircle,
+ ShieldCheck,
+ UserPlus,
+ TrendingUp,
+ TrendingDown,
+ Eye, // Added Eye for views
+ MousePointer, // Added MousePointer for clicks
+ Repeat, // Added Repeat for conversions
+ BarChart, // Added BarChart for totalAds (changed from generic)
+ PieChart, // Added PieChart for distribution/summary (generic)
+ CreditCard, // Used for one revenue type
+ Wallet, // Used for another revenue type
+ Banknote, // Used for the third revenue type
+ LineChart, // Another option for totalAds or general analytics
+} from 'lucide-react';
+
+const iconMap = {
+ orders: ,
+ users: ,
+ stores: ,
+ products: ,
+ income: ,
+ activeStores: ,
+ approvedUsers: ,
+ newUsers: ,
+ totalAds: , // Updated icon for totalAds
+ views: , // Icon for views
+ clicks: , // Icon for clicks
+ conversions: , // Icon for conversions
+ conversionRevenue: , // Updated icon for conversion revenue
+ clicksRevenue: , // Updated icon for clicks revenue
+ viewsRevenue: , // Updated icon for views revenue
+};
+
+const KpiCard = ({ label, value, percentageChange = 0, type = 'orders' }) => {
+ const isPositive = percentageChange >= 0;
+ const { t } = useTranslation();
+ return (
+
+
+ {iconMap[type]}
+
+
+
+
+ {label}
+
+
+ {Number(value) % 1 === 0
+ ? Number(value)
+ : Number(value).toFixed(2)}{' '}
+
+
+
+
+ {isPositive ? : }
+
+ {Math.abs(Number(percentageChange)).toFixed(2)}% {t('analytics.comparedToLastMonth')}
+
+
+
+ );
+};
+
+export default KpiCard;
diff --git a/src/components/LockOverlay.jsx b/src/components/LockOverlay.jsx
new file mode 100644
index 0000000..55d5921
--- /dev/null
+++ b/src/components/LockOverlay.jsx
@@ -0,0 +1,25 @@
+// @components/LockOverlay.jsx
+import { Box, Typography } from '@mui/material';
+import LockIcon from '@mui/icons-material/Lock';
+
+export default function LockOverlay({ message = 'Open this ticket' }) {
+ return (
+
+
+
+ {message}
+
+
+ );
+}
diff --git a/src/components/MetricCard.jsx b/src/components/MetricCard.jsx
new file mode 100644
index 0000000..532eef8
--- /dev/null
+++ b/src/components/MetricCard.jsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { Card, CardContent, Typography, Box, Tooltip } from '@mui/material';
+import { Info } from 'lucide-react';
+
+
+const MetricCard = ({ title, value, subtitle, icon, color, tooltipText, trend, trendValue }) => {
+ return (
+
+
+
+
+ {title}
+ {tooltipText && (
+
+
+
+
+
+ )}
+
+ {icon && (
+
+ {icon}
+
+ )}
+
+
+
+ {value}
+
+
+ {subtitle && (
+
+ {subtitle}
+
+ )}
+
+ {trend && (
+
+ {trend === 'up' ? '↑' : trend === 'down' ? '↓' : '•'}
+
+ {trendValue}
+
+
+ )}
+
+
+ );
+};
+
+export default MetricCard;
\ No newline at end of file
diff --git a/src/components/NewProductModal.jsx b/src/components/NewProductModal.jsx
new file mode 100644
index 0000000..600ca12
--- /dev/null
+++ b/src/components/NewProductModal.jsx
@@ -0,0 +1,322 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Modal,
+ Box,
+ TextField,
+ Button,
+ Typography,
+ MenuItem,
+ useTheme,
+} from '@mui/material';
+import ImageUploader from './ImageUploader';
+import SuccessMessage from './SuccessMessage';
+import { HiOutlineCube } from 'react-icons/hi';
+import style from './NewProductModalStyle';
+import {
+ apiCreateProductAsync,
+ apiGetProductCategoriesAsync,
+} from '../api/api';
+
+const weightUnits = ['kg', 'g', 'lbs'];
+const volumeUnits = ['L', 'ml', 'oz'];
+
+const AddProductModal = ({ open, onClose, storeID }) => {
+ const theme = useTheme();
+
+ const [productCategories, setProductCategories] = useState([]);
+ const [formData, setFormData] = useState({
+ name: '',
+ price: '',
+ weight: '',
+ weightunit: 'kg',
+ volume: '',
+ volumeunit: 'L',
+ productcategoryname: '',
+ photos: [],
+ });
+
+ const [successModal, setSuccessModal] = useState({
+ open: false,
+ isSuccess: true,
+ message: '',
+ });
+
+ useEffect(() => {
+ if (open) {
+ apiGetProductCategoriesAsync().then(setProductCategories);
+ }
+ }, [open]);
+
+ useEffect(() => {
+ if (successModal.open) {
+ const timer = setTimeout(() => {
+ setSuccessModal((prev) => ({ ...prev, open: false }));
+ }, 1500);
+ return () => clearTimeout(timer);
+ }
+ }, [successModal.open]);
+
+ const handleChange = (e) => {
+ const { name, value } = e.target;
+
+ if (name === 'productcategoryid') {
+ const selectedCategory = productCategories.find(
+ (cat) => cat.name === value
+ );
+
+ setFormData((prev) => ({
+ ...prev,
+ productcategoryid: selectedCategory ? selectedCategory.id : 0,
+ }));
+ } else {
+ setFormData((prev) => ({ ...prev, [name]: value }));
+ }
+ };
+
+ const handlePhotosChange = (files) => {
+ setFormData((prev) => ({ ...prev, photos: files }));
+ };
+
+ const handleSubmit = async () => {
+ const selectedCategory = productCategories.find(
+ (cat) => cat.name === formData.productcategoryname
+ );
+
+ if (!selectedCategory) {
+ alert('Please select a valid product category.');
+ return;
+ }
+
+ const productData = {
+ name: formData.name,
+ price: formData.price,
+ weight: formData.weight,
+ weightunit: formData.weightunit,
+ volume: formData.volume,
+ volumeunit: formData.volumeunit,
+ productcategoryid: selectedCategory.id,
+ storeId: storeID,
+ photos: formData.photos,
+ };
+
+ try {
+ const response = await apiCreateProductAsync(productData);
+ if (response?.status >= 200 && response?.status < 300) {
+ setSuccessModal({
+ open: true,
+ isSuccess: true,
+ message: 'Product has been successfully assigned to the store.',
+ });
+ } else {
+ throw new Error('API returned failure.');
+ }
+ } catch (err) {
+ setSuccessModal({
+ open: true,
+ isSuccess: false,
+ message: 'Failed to assign product to the store.',
+ });
+ } finally {
+ onClose();
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+ Add New Product
+
+
+
+
+ {/* Product Form */}
+
+
+
+
+
+
+
+
+
+
+
+ {weightUnits.map((unit) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ {volumeUnits.map((unit) => (
+
+ ))}
+
+
+
+
+
+ {productCategories.map((cat) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+ setSuccessModal((prev) => ({ ...prev, open: false }))}
+ isSuccess={successModal.isSuccess}
+ message={successModal.message}
+ />
+ >
+ );
+};
+
+export default AddProductModal;
diff --git a/src/components/NewProductModalStyle.jsx b/src/components/NewProductModalStyle.jsx
new file mode 100644
index 0000000..c952f9a
--- /dev/null
+++ b/src/components/NewProductModalStyle.jsx
@@ -0,0 +1,14 @@
+const style = {
+ position: "absolute",
+ top: "50%",
+ left: "50%",
+ transform: "translate(-50%, -50%)",
+ width: 500,
+ bgcolor: "background.paper",
+ color: "black",
+ boxShadow: 24,
+ p: 4,
+ borderRadius: 4,
+ };
+
+ export default style;
\ No newline at end of file
diff --git a/src/components/OrderComponent.jsx b/src/components/OrderComponent.jsx
new file mode 100644
index 0000000..883fbc1
--- /dev/null
+++ b/src/components/OrderComponent.jsx
@@ -0,0 +1,411 @@
+import React, { useState, useMemo, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ IconButton,
+ Typography,
+ Box,
+ Button,
+ Divider,
+ TextField,
+ Chip,
+ MenuItem,
+} from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import { FaPen, FaCheck } from 'react-icons/fa';
+import OrderItemCard from './OrderItemCard';
+import {
+ apiUpdateOrderAsync,
+ apiGetAllStoresAsync,
+ apiFetchApprovedUsersAsync,
+} from '@api/api';
+
+const statusOptions = [
+ 'Requested',
+ 'Confirmed',
+ 'Rejected',
+ 'Ready',
+ 'Sent',
+ 'Delivered',
+ 'Cancelled',
+];
+
+const getStatusColor = (status) => {
+ switch (status.toLowerCase()) {
+ case 'confirmed':
+ return '#0288d1'; // plava
+ case 'rejected':
+ return '#d32f2f'; // crvena
+ case 'ready':
+ return '#388e3c'; // zelena
+ case 'sent':
+ return '#fbc02d'; // žuta
+ case 'delivered':
+ return '#1976d2'; // tamno plava
+ case 'cancelled':
+ return '#b71c1c'; // tamno crvena
+ case 'requested':
+ return '#757575'; // siva
+ default:
+ return '#9e9e9e'; // fallback siva
+ }
+};
+
+const OrderComponent = ({ open, onClose, narudzba, onOrderUpdated }) => {
+ const [editMode, setEditMode] = useState(false);
+ const [status, setStatus] = useState(narudzba.status);
+ const [buyerId, setBuyerId] = useState(null);
+ const [storeId, setStoreId] = useState(null);
+ const [buyerName] = useState(narudzba.buyerId);
+ const [storeName] = useState(narudzba.storeId);
+ const [storeAddress] = useState(narudzba.storeAddress);
+ const [deliveryAddress] = useState(narudzba.deliveryAddress);
+
+ const [date, setDate] = useState(
+ new Date(narudzba.time).toISOString().slice(0, 16)
+ );
+ const [products, setProducts] = useState(narudzba.proizvodi || []);
+deliveryAddress
+ useEffect(() => {
+ const fetchMappings = async () => {
+ const [stores, users] = await Promise.all([
+ apiGetAllStoresAsync(),
+ apiFetchApprovedUsersAsync(),
+ ]);
+
+ const storeEntry = stores.find((s) => s.name === narudzba.storeId);
+ const userEntry = users.find(
+ (u) => u.userName === narudzba.buyerId || u.email === narudzba.buyerId
+ );
+
+ if (storeEntry) {
+ setStoreId(storeEntry.id);
+ }
+
+ if (userEntry) {
+ setBuyerId(userEntry.id);
+ }
+ };
+
+ fetchMappings();
+ }, [narudzba.buyerId, narudzba.storeId]);
+
+ const handleProductChange = (index, changes) => {
+ setProducts((prev) =>
+ prev.map((item, i) => (i === index ? { ...item, ...changes } : item))
+ );
+ };
+
+ const total = useMemo(() => {
+ return products.reduce(
+ (sum, p) => sum + parseFloat(p.price || 0) * parseInt(p.quantity || 0),
+ 0
+ );
+ }, [products]);
+
+ const handleSaveChanges = async () => {
+ const originalOrderItems = narudzba.orderItems || [];
+
+ if (originalOrderItems.length !== products.length) {
+ alert('Greška: broj proizvoda se ne poklapa.');
+ return;
+ }
+
+ if (!storeId) {
+ alert('Greška: Store ID nije validan.');
+ console.log(storeId);
+ return;
+ }
+
+ if (!buyerId) {
+ alert('Greška: Buyer ID nije validan.');
+ return;
+ }
+
+ const payload = {
+ buyerId: String(buyerId),
+ storeId,
+ status,
+ time: new Date(date).toISOString(),
+ total,
+ orderItems: products.map((p, i) => {
+ const original = originalOrderItems[i];
+ return {
+ id: Number(original.id),
+ productId: Number(original.productId),
+ price: Number(p.price),
+ quantity: Number(p.quantity),
+ };
+ }),
+ };
+
+ const res = await apiUpdateOrderAsync(narudzba.id, payload);
+
+ if (res.success) {
+ setEditMode(false);
+ onClose();
+ window.location.reload();
+ } else {
+ alert('Neuspješno ažuriranje narudžbe.');
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default OrderComponent;
diff --git a/src/components/OrderItemCard.jsx b/src/components/OrderItemCard.jsx
new file mode 100644
index 0000000..3d939c0
--- /dev/null
+++ b/src/components/OrderItemCard.jsx
@@ -0,0 +1,132 @@
+import { Box, Typography, Avatar, TextField } from '@mui/material';
+
+const OrderItemCard = ({
+ imageUrl,
+ name,
+ price,
+ quantity,
+ tagIcon = '🏷️',
+ tagLabel = 'General',
+ isEditable = false,
+ onChange = () => {},
+}) => {
+ return (
+
+ {/* Left: Image */}
+
+
+ {/* Right: Info */}
+
+ {/* Name & Tag */}
+
+
+ {name}
+
+
+
+
+ {tagIcon}
+
+ {tagLabel}
+
+
+
+ {/* Quantity and Price */}
+
+ {isEditable ? (
+ <>
+
+ onChange({
+ quantity: parseInt(e.target.value) || 0,
+ })
+ }
+ sx={{ width: 50 }}
+ />
+
+ onChange({
+ price: parseFloat(e.target.value) || 0,
+ })
+ }
+ sx={{ width: 80 }}
+ />
+ >
+ ) : (
+ <>
+
+ {quantity}
+
+
+ ${parseFloat(price).toFixed(2)}
+
+ >
+ )}
+
+
+
+ );
+};
+
+export default OrderItemCard;
diff --git a/src/components/OrdersByStatus.jsx b/src/components/OrdersByStatus.jsx
new file mode 100644
index 0000000..0271e41
--- /dev/null
+++ b/src/components/OrdersByStatus.jsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useState } from 'react';
+import { Card, CardContent, Typography, Box } from '@mui/material';
+import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
+import { apiGetAllAdsAsync } from '../api/api.js';
+import { useTranslation } from 'react-i18next';
+
+// Dodijeli boje svakom triggeru
+const triggerColors = {
+ Search: '#6366F1',
+ Order: '#F59E0B',
+ View: '#10B981',
+};
+
+const triggerLabels = ['Search', 'Order', 'View'];
+
+const OrdersBystatus = () => {
+ const { t } = useTranslation();
+ const [data, setData] = useState([]);
+
+ useEffect(() => {
+ const fetchAds = async () => {
+ const adsRepsonse = await apiGetAllAdsAsync();
+ const ads = adsRepsonse.data;
+ // Broji koliko reklama ima svaki trigger
+ const triggerCounts = { Search: 0, Order: 0, View: 0 };
+ ads.forEach((ad) => {
+ if (Array.isArray(ad.triggers)) {
+ ad.triggers.forEach((trigger) => {
+ if (Object.prototype.hasOwnProperty.call(triggerCounts, trigger)) {
+ triggerCounts[trigger]++;
+ }
+ });
+ }
+ });
+ console.log('TREGER: ', triggerCounts);
+ // Pripremi podatke za PieChart
+ const chartData = triggerLabels.map((trigger) => ({
+ name: trigger,
+ value: triggerCounts[trigger],
+ color: triggerColors[trigger],
+ }));
+
+ setData(chartData);
+ };
+
+ fetchAds();
+ }, []);
+
+ return (
+
+
+
+ {t('analytics.adTriggersBreakdown')}
+
+
+
+
+
+
+ {data.map((entry, idx) => (
+ |
+ ))}
+
+
+
+
+
+ {data.map((entry) => (
+
+
+
+ {entry.name} ({entry.value})
+
+
+ ))}
+
+
+ );
+};
+
+export default OrdersBystatus;
diff --git a/src/components/OrdersTable.jsx b/src/components/OrdersTable.jsx
new file mode 100644
index 0000000..e4eea7e
--- /dev/null
+++ b/src/components/OrdersTable.jsx
@@ -0,0 +1,177 @@
+import React from 'react';
+import {
+ Box,
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Chip,
+ IconButton,
+ Tooltip,
+} from '@mui/material';
+import { FaTrash } from 'react-icons/fa6';
+import CircleIcon from '@mui/icons-material/FiberManualRecord';
+import ArrowDropUpIcon from '@mui/icons-material/ArrowDropUp';
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+import { useTranslation } from 'react-i18next';
+
+const getStatusColor = (status) => {
+ switch (status) {
+ case 'confirmed':
+ return '#0288d1'; // plava
+ case 'rejected':
+ return '#d32f2f'; // crvena
+ case 'ready':
+ return '#388e3c'; // zelena
+ case 'sent':
+ return '#fbc02d'; // žuta
+ case 'delivered':
+ return '#1976d2'; // tamno plava
+ case 'cancelled':
+ return '#b71c1c'; // tamno crvena
+ case 'requested': // Dodaj boju i za requested
+ return '#03e8fc';
+ default:
+ return '#9e9e9e'; // siva
+ }
+};
+
+const OrdersTable = ({
+ orders,
+ sortField,
+ sortOrder,
+ onSortChange,
+ onOrderClick,
+ onDelete,
+}) => {
+ const handleSort = (field) => {
+ const order = field === sortField && sortOrder === 'asc' ? 'desc' : 'asc';
+ onSortChange(field, order);
+ };
+
+ const formatOrderId = (id) => `#${String(id).padStart(5, '0')}`;
+ const { t } = useTranslation();
+ const columns = [
+ { label: t('common.orderNumber'), field: 'id' },
+ { label: t('common.buyer'), field: 'buyerName' },
+ { label: t('common.store'), field: 'storeName' },
+ { label: t('common.deliveryAddress'), field: 'deliveryAddress' }, // NOVA KOLONA
+ { label: t('common.storeAddress'), field: 'storeAddress' }, // NOVA KOLONA
+ { label: t('common.status'), field: 'status' },
+ { label: t('common.total'), field: 'totalPrice' },
+ { label: t('common.created'), field: 'createdAt' },
+ { label: '', field: 'actions' },
+ ];
+
+ return (
+
+
+
+
+ {columns.map((col) => (
+ col.field !== 'actions' && handleSort(col.field)}
+ sx={{
+ fontWeight: 'bold',
+ color: '#000',
+ cursor: col.field !== 'actions' ? 'pointer' : 'default',
+ userSelect: 'none',
+ whiteSpace: 'nowrap',
+ '&:hover': {
+ color: col.field !== 'actions' ? '#444' : undefined,
+ '.sort-icon': { opacity: 1, color: '#444' },
+ },
+ }}
+ >
+
+ {col.label}
+ {col.field !== 'actions' && (
+
+ {sortOrder === 'asc' ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ ))}
+
+
+
+ {orders.map((order) => (
+ onOrderClick(order)}
+ >
+
+ {formatOrderId(order.id)}
+
+ {order.buyerName}
+ {order.storeName}
+ {order.deliveryAddress} {/* NOVA ĆELIJA */}
+ {order.storeAddress} {/* NOVA ĆELIJA */}
+
+
+
+ ${order.totalPrice}
+
+ {order.createdAt ? // Provjeri da createdAt postoji
+ new Date(order.createdAt).toLocaleDateString(undefined, {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }) : 'N/A'}
+
+
+
+ {
+ e.stopPropagation();
+ onDelete(order.id);
+ }}
+ >
+
+
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export default OrdersTable;
diff --git a/src/components/ParetoChart.jsx b/src/components/ParetoChart.jsx
new file mode 100644
index 0000000..623ac26
--- /dev/null
+++ b/src/components/ParetoChart.jsx
@@ -0,0 +1,201 @@
+import React, { useEffect, useState, useRef } from 'react';
+import {
+ ResponsiveContainer,
+ ComposedChart,
+ Bar,
+ Line,
+ XAxis,
+ YAxis,
+ Tooltip,
+ CartesianGrid,
+ Area,
+ Legend,
+} from 'recharts';
+import { Box, Typography } from '@mui/material';
+import { apiGetAllAdsAsync } from '../api/api.js';
+import { format, parseISO } from 'date-fns';
+import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
+import { useTranslation } from 'react-i18next';
+
+const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
+const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub';
+const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`;
+
+function groupByMonth(ads) {
+ const byMonth = {};
+ ads.forEach((ad) => {
+ const date = ad.startTime || ad.endTime;
+ if (!date) return;
+ const month = format(parseISO(date), 'yyyy-MM');
+ if (!byMonth[month])
+ byMonth[month] = { month, clicks: 0, views: 0, conversions: 0 };
+ byMonth[month].clicks += ad.clicks || 0;
+ byMonth[month].views += ad.views || 0;
+ byMonth[month].conversions += ad.conversions || 0;
+ });
+ return Object.values(byMonth).sort((a, b) => a.month.localeCompare(b.month));
+}
+
+const ParetoChart = () => {
+ const [data, setData] = useState([]);
+ const [ads, setAds] = useState([]);
+ const connectionRef = useRef(null);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const adsResponse = await apiGetAllAdsAsync();
+ const adsData = adsResponse.data;
+ setAds(adsData);
+ updateChartData(adsData);
+ };
+
+ const updateChartData = (ads) => {
+ const chartData = groupByMonth(ads).map((d) => ({
+ time: format(parseISO(d.month + '-01'), 'MMM yyyy'),
+ clicks: d.clicks,
+ views: d.views,
+ conversions: d.conversions,
+ }));
+ setData(chartData);
+ };
+
+ fetchData();
+
+ // SignalR Setup
+ const jwtToken = localStorage.getItem('token');
+ if (!jwtToken) {
+ console.warn('No JWT token found. SignalR connection not started.');
+ return;
+ }
+
+ const connection = new HubConnectionBuilder()
+ .withUrl(HUB_URL, {
+ accessTokenFactory: () => jwtToken,
+ })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(LogLevel.Information)
+ .build();
+
+ connectionRef.current = connection;
+
+ const startConnection = async () => {
+ try {
+ await connection.start();
+ console.log('SignalR Connected to AdvertisementHub!');
+ } catch (err) {
+ console.error('SignalR Connection Error:', err);
+ }
+ };
+
+ startConnection();
+
+ // Register event handlers
+ connection.on('ReceiveAdUpdate', (updatedAd) => {
+ setAds((prevAds) => {
+ const updatedAds = prevAds.map((ad) =>
+ ad.id === updatedAd.id ? updatedAd : ad
+ );
+ updateChartData(updatedAds);
+ return updatedAds;
+ });
+ });
+
+ // Cleanup on unmount
+ return () => {
+ if (
+ connectionRef.current &&
+ connectionRef.current.state === 'Connected'
+ ) {
+ connectionRef.current
+ .stop()
+ .catch((err) =>
+ console.error('Error stopping SignalR connection:', err)
+ );
+ }
+ };
+ }, []);
+
+ return (
+
+
+ {t('analytics.paretoChart')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ParetoChart;
diff --git a/src/components/PendingUsersTable.jsx b/src/components/PendingUsersTable.jsx
new file mode 100644
index 0000000..0f2f036
--- /dev/null
+++ b/src/components/PendingUsersTable.jsx
@@ -0,0 +1,223 @@
+// PendingUsersTable.jsx
+import React, { useState } from "react";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Avatar,
+ TableSortLabel,
+ Box,
+ Typography,
+ Chip,
+} from "@mui/material";
+import { styled } from "@mui/material/styles";
+import ApproveUserButton from "./ApproveUserButton";
+import DeleteUserButton from "./DeleteUserButton";
+import axios from 'axios';
+import { apiApproveUserAsync, apiDeleteUserAsync } from "../api/api";
+import { useTranslation } from 'react-i18next';
+
+var baseURL = import.meta.env.VITE_API_BASE_URL
+
+
+const StyledTableContainer = styled(TableContainer)(({ theme }) => ({
+ maxHeight: 840,
+ overflow: "auto",
+ borderRadius: 8,
+ boxShadow: "0 2px 8px #800000",
+}));
+
+const StyledTableRow = styled(TableRow)(({ theme }) => ({
+ transition: "background-color 0.2s ease",
+ cursor: "pointer",
+ "&:hover": {
+ backgroundColor: "rgba(0, 0, 0, 0.04)",
+ },
+}));
+
+const PendingUsersTable = ({
+ users = [],
+ onApprove,
+ onDelete,
+ onView,
+ currentPage,
+ usersPerPage,
+}) => {
+ const { t } = useTranslation();
+ const [orderBy, setOrderBy] = useState("submitDate");
+ const [order, setOrder] = useState("desc");
+
+ const handleRequestSort = (property) => {
+ const isAsc = orderBy === property && order === "asc";
+ setOrder(isAsc ? "desc" : "asc");
+ setOrderBy(property);
+ };
+
+ const compareValues = (a, b, orderBy) => {
+ if (!a[orderBy]) return 1;
+ if (!b[orderBy]) return -1;
+ if (typeof a[orderBy] === "string") {
+ return a[orderBy].toLowerCase().localeCompare(b[orderBy].toLowerCase());
+ }
+ return a[orderBy] < b[orderBy] ? -1 : 1;
+ };
+
+ const sortedUsers = [...users].sort((a, b) => {
+ return order === "asc"
+ ? compareValues(a, b, orderBy)
+ : compareValues(b, a, orderBy);
+ });
+
+ const formatDate = (dateString) => {
+ if (!dateString) return "N/A";
+ try {
+ const date = new Date(dateString);
+ return date.toLocaleDateString("en-GB", {
+ day: "2-digit",
+ month: "short",
+ year: "numeric",
+ });
+ } catch {
+ return dateString;
+ }
+ };
+
+ return (
+
+
+
+
+ #
+ {t('common.picture')}
+
+ handleRequestSort("name")}
+ >
+ {t('common.name')}
+
+
+
+ handleRequestSort("email")}
+ >
+ {t('common.email')}
+
+
+
+ handleRequestSort("role")}
+ >
+ {t('common.role')}
+
+
+ {t('common.actions')}
+
+
+
+ {sortedUsers.map((user, index) => (
+ onView(user.id)}>
+
+ {(currentPage - 1) * usersPerPage + index + 1}
+
+
+
+
+
+
+ {user.userName}
+
+
+ {user.email}
+ {user.roles ? user.roles[0] : "?"}
+
+
+
+
+ {
+ e.preventDefault();
+ onApprove(user.id);
+
+ //await apiApproveUserAsync(user.id);
+ // const token = localStorage.getItem("token");
+
+ // if (token) {
+ // axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
+ // }
+
+ // const Payload = {
+ // userId:user.id
+ // };
+
+ // axios
+
+ // .post(`${baseURL}/api/Admin/users/approve`, Payload)
+
+ // .then((response) => {
+ // console.log("User approved successfully:", response.data);
+ // // optionally redirect or clear form inputs
+ // })
+ // .catch((error) => {
+ // console.error("Error approving user:", error);
+ // });
+ }
+ }
+ />
+ {
+ e.stopPropagation();
+ onDelete(user.id);
+ await apiDeleteUserAsync(user.id)
+
+ // const token = localStorage.getItem("token");
+
+ // if (token) {
+ // axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
+ // }
+
+ // const Payload = {
+ // userId:user.id
+ // };
+ // axios
+ // .delete(`http://localhost:5054/api/Admin/users/${user.id}`)
+ // .then((response) => {
+ // console.log("User deleted successfully:", response.data);
+ // // optionally redirect or clear form inputs
+ // })
+ // .catch((error) => {
+ // console.error("Error deleting user:", error);
+ // });
+ }}
+ />
+
+
+
+ ))}
+ {users.length === 0 && (
+
+
+ {t('common.noPendingUsersFound')}
+
+
+ )}
+
+
+
+ );
+};
+
+export default PendingUsersTable;
diff --git a/src/components/ProductDetailsModal.jsx b/src/components/ProductDetailsModal.jsx
new file mode 100644
index 0000000..73ac2e8
--- /dev/null
+++ b/src/components/ProductDetailsModal.jsx
@@ -0,0 +1,233 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogContent,
+ Typography,
+ Box,
+ Divider,
+ Chip,
+ useTheme,
+} from '@mui/material';
+import StoreIcon from '@mui/icons-material/Store';
+import CategoryIcon from '@mui/icons-material/Category';
+import ScaleIcon from '@mui/icons-material/MonitorWeight';
+import VolumeUpIcon from '@mui/icons-material/Opacity';
+import { apiGetAllStoresAsync, apiGetProductCategoriesAsync } from '@api/api';
+
+const ProductDetailsModal = ({ open, onClose, product }) => {
+ const theme = useTheme();
+ const [activeImage, setActiveImage] = useState(null);
+ const [storeName, setStoreName] = useState('');
+ const [categoryName, setCategoryName] = useState('');
+
+ useEffect(() => {
+ if (product?.photos?.length) {
+ const first =
+ typeof product.photos[0] === 'string'
+ ? product.photos[0]
+ : product.photos[0]?.path;
+ setActiveImage(resolveImage(first));
+ }
+ }, [product]);
+
+ useEffect(() => {
+ if (open && product) {
+ loadStoreAndCategory();
+ }
+ }, [open, product]);
+
+ const loadStoreAndCategory = async () => {
+ try {
+ const [stores, categories] = await Promise.all([
+ apiGetAllStoresAsync(),
+ apiGetProductCategoriesAsync(),
+ ]);
+
+ const foundStore = stores.find((s) => s.id === product.storeId);
+ const foundCategory = categories.find(
+ (c) => c.id === product.productCategory?.id
+ );
+
+ setStoreName(foundStore?.name || 'Unknown Store');
+ setCategoryName(foundCategory?.name || 'Unknown Category');
+ } catch (err) {
+ console.error('Greška prilikom učitavanja store/kategorije:', err);
+ }
+ };
+
+ const resolveImage = (path) => {
+ if (!path) return '';
+ return path.startsWith('http')
+ ? path
+ : `${import.meta.env.VITE_API_BASE_URL}${path}`;
+ };
+
+ if (!product) return null;
+
+ const {
+ name,
+ retailPrice,
+ weight,
+ weightUnit,
+ volume,
+ volumeUnit,
+ isActive,
+ photos = [],
+ } = product;
+
+ const normalizedPhotos = photos.map((p) =>
+ resolveImage(typeof p === 'string' ? p : p?.path)
+ );
+
+ return (
+
+ );
+};
+
+export default ProductDetailsModal;
diff --git a/src/components/ProductsSummary.jsx b/src/components/ProductsSummary.jsx
new file mode 100644
index 0000000..b480849
--- /dev/null
+++ b/src/components/ProductsSummary.jsx
@@ -0,0 +1,128 @@
+import React, { useEffect, useState } from 'react';
+import { Paper, Typography, Box, Grid, Divider } from '@mui/material';
+import { Megaphone, ShoppingBag, CheckCircle, TrendingUp } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import { apiFetchAdsWithProfitAsync } from '@api/api';
+
+const ProductSummary = ({ product, ads }) => {
+ const { t } = useTranslation();
+ const [adsData, setAdsData] = useState([]);
+
+ useEffect(() => {
+ const loadAds = async () => {
+ if (!ads) {
+ try {
+ const ads = await apiFetchAdsWithProfitAsync();
+ console.log('✅ Fetched ads with profit:', ads);
+ setAdsData(ads);
+ } catch (error) {
+ console.error('❌ Error loading ads:', error);
+ }
+ } else {
+ setAdsData(ads);
+ }
+ };
+
+ loadAds();
+ }, []);
+
+ const totalViews = adsData.reduce((sum, ad) => sum + ad.views, 0);
+ const totalClicks = adsData.reduce((sum, ad) => sum + ad.clicks, 0);
+ const totalConversions = adsData.reduce((sum, ad) => sum + ad.conversions, 0);
+ const totalProfit = adsData.reduce((sum, ad) => sum + ad.profit, 0);
+ console.log(product);
+ return (
+
+
+
+
+
+
+ {product?.name.length > 54
+ ? `${product.name.substring(0, 54)}...`
+ : product.name || t('common.unknownProduct')}{' '}
+
+
+
+
+
+
+
+
+
+
+ {t('analytics.totalViews')}
+
+
+
+
+ {totalViews > 0 ? totalViews : 0}
+
+
+
+
+
+
+
+
+ {t('analytics.totalClicks')}
+
+
+
+
+ {totalClicks > 0 ? totalClicks : 0}
+
+
+
+
+
+
+
+
+ {t('analytics.totalConversions')}
+
+
+
+
+ {totalConversions > 0 ? totalConversions : 0}
+
+
+
+
+
+
+
+
+
+ {t('analytics.totalEarnedProfitFromAds')}
+
+
+ ${totalProfit > 0 ? totalProfit.toFixed(2) : 0}
+
+
+
+
+
+
+
+ );
+};
+
+export default ProductSummary;
diff --git a/src/components/RevenueByStore.jsx b/src/components/RevenueByStore.jsx
new file mode 100644
index 0000000..572f69a
--- /dev/null
+++ b/src/components/RevenueByStore.jsx
@@ -0,0 +1,125 @@
+import React, { useEffect, useState } from 'react';
+import { Card, CardContent, Typography, Box } from '@mui/material';
+import {
+ BarChart,
+ Bar,
+ XAxis,
+ YAxis,
+ Tooltip,
+ ResponsiveContainer,
+ Cell,
+} from 'recharts';
+import { apiGetAllStoresAsync, apiGetAllAdsAsync } from '../api/api.js';
+import { useTranslation } from 'react-i18next';
+
+const barColor = '#6366F1';
+const TOP_N = 5;
+
+const RevenueByStore = () => {
+ const { t } = useTranslation();
+ const [data, setData] = useState([]);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const [stores, adsResponse] = await Promise.all([
+ apiGetAllStoresAsync(),
+ apiGetAllAdsAsync(),
+ ]);
+ const ads = adsResponse.data;
+ console.log('Ads: ', ads);
+
+ // Mapiraj storeId na ime prodavnice
+ const storeMap = {};
+ stores.forEach((store) => {
+ storeMap[store.id] = store.name;
+ });
+ console.log('STOREMAP: ', storeMap);
+ // Grupiraj zaradu po storeId iz adData
+ const revenueByStore = {};
+ ads.forEach((ad) => {
+ if (!ad.conversionPrice || ad.conversionPrice === 0) return;
+ // Za svaki adData sa storeId, dodaj cijelu conversionPrice toj prodavnici
+ ad.adData.forEach((adDataItem) => {
+ if (!adDataItem.storeId) return;
+ const storeId = adDataItem.storeId;
+ if (!storeMap[storeId]) return;
+ revenueByStore[storeId] =
+ (revenueByStore[storeId] || 0) + ad.conversionPrice;
+ });
+ });
+ console.log('RevenueByStore: ', revenueByStore);
+
+ const chartData = Object.entries(revenueByStore)
+ .map(([storeId, value]) => ({
+ name: storeMap[storeId] || `Store #${storeId}`,
+ value,
+ }))
+ .sort((a, b) => b.value - a.value)
+ .slice(0, TOP_N);
+
+ setData(chartData);
+ };
+
+ fetchData();
+ }, []);
+
+ return (
+
+
+
+ {t('analytics.topStoresByAdRevenue')}
+
+
+
+
+
+ `$${v.toFixed(0)}`}
+ axisLine={false}
+ tickLine={false}
+ tick={{ fontSize: 13, dy: 2 }}
+ />
+
+ `$${val}`} />
+
+ {data.map((entry, idx) => (
+ |
+ ))}
+
+
+
+
+
+ );
+};
+
+export default RevenueByStore;
diff --git a/src/components/RevenueMetrics.jsx b/src/components/RevenueMetrics.jsx
new file mode 100644
index 0000000..bbdc3e2
--- /dev/null
+++ b/src/components/RevenueMetrics.jsx
@@ -0,0 +1,248 @@
+import React, { useEffect, useState } from 'react';
+import { Grid, Paper, Typography, Box } from '@mui/material';
+import { LineChart } from '@mui/x-charts/LineChart';
+import { PieChart } from '@mui/x-charts/PieChart';
+import { DollarSign, Eye, MousePointerClick, ShoppingCart } from 'lucide-react';
+import { useTranslation } from 'react-i18next';
+import MetricCard from './MetricCard';
+import { apiFetchAdsWithProfitAsync } from '@api/api';
+
+const formatCurrency = (value, currency = 'USD') =>
+ new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ }).format(value);
+
+const groupByDay = (ads, eventType) => {
+ const days = Array(30).fill(0);
+ const today = new Date();
+
+ ads.forEach((ad) => {
+ const eventCount = ad[eventType];
+ const price = ad[`${eventType.slice(0, -1)}Price`];
+
+ const date = new Date(ad.startTime);
+ const diffDays = Math.floor((today - date) / (1000 * 60 * 60 * 24));
+ if (diffDays >= 0 && diffDays < 30) {
+ days[29 - diffDays] += eventCount * price;
+ }
+ });
+
+ return days;
+};
+
+const RevenueMetrics = () => {
+ const { t } = useTranslation();
+ const [ads, setAds] = useState([]);
+ // const { t } = useTranslation();
+ useEffect(() => {
+ const fetchData = async () => {
+ const adsData = await apiFetchAdsWithProfitAsync();
+ setAds(adsData);
+ };
+ fetchData();
+ }, []);
+
+ const totalRevenue = ads.reduce(
+ (acc, ad) =>
+ acc +
+ ad.clicks * ad.clickPrice +
+ ad.views * ad.viewPrice +
+ ad.conversions * ad.conversionPrice,
+ 0
+ );
+
+ const clickRevenue = ads.reduce(
+ (sum, ad) => sum + ad.clicks * ad.clickPrice,
+ 0
+ );
+ const viewRevenue = ads.reduce((sum, ad) => sum + ad.views * ad.viewPrice, 0);
+ const conversionRevenue = ads.reduce(
+ (sum, ad) => sum + ad.conversions * ad.conversionPrice,
+ 0
+ );
+
+ const revenueBySource = [
+ { id: 0, value: clickRevenue, label: 'Click Revenue', color: '#3B82F6' },
+ { id: 1, value: viewRevenue, label: 'View Revenue', color: '#0D9488' },
+ {
+ id: 2,
+ value: conversionRevenue,
+ label: 'Conversion Revenue',
+ color: '#10B981',
+ },
+ ];
+
+ const clickRevenueByDay = groupByDay(ads, 'clicks');
+ const viewRevenueByDay = groupByDay(ads, 'views');
+ const conversionRevenueByDay = groupByDay(ads, 'conversions');
+
+ const dateLabels = Array(30)
+ .fill()
+ .map((_, i) => {
+ const d = new Date();
+ d.setDate(d.getDate() - (29 - i));
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ });
+
+ const xAxisLabels = dateLabels.map((label, i) => (i % 5 === 0 ? label : ''));
+
+ return (
+
+
+ {t('analytics.revenueAndProfitAnalysis')}
+
+
+
+
+ }
+ color='success'
+ tooltipText={t('analytics.fromAllAdvertisingSources')}
+ />
+
+
+
+ s + a.clicks, 0).toLocaleString(),
+ })}
+ icon={ }
+ color='info'
+ tooltipText={t('analytics.clickRevenue')}
+ />
+
+
+
+ s + a.views, 0).toLocaleString(),
+ })}
+ icon={ }
+ color='secondary'
+ tooltipText={t('analytics.viewRevenue')}
+ />
+
+
+
+ s + a.conversions, 0)
+ .toLocaleString(),
+ })}
+ icon={ }
+ color='success'
+ tooltipText={t('analytics.conversionRevenue')}
+ />
+
+
+
+
+
+
+
+ {t('analytics.revenueBySourceOverTime')}
+
+
+
+
+
+
+
+ {t('analytics.revenueDistribution')}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default RevenueMetrics;
diff --git a/src/components/RouteCard.jsx b/src/components/RouteCard.jsx
new file mode 100644
index 0000000..09818df
--- /dev/null
+++ b/src/components/RouteCard.jsx
@@ -0,0 +1,109 @@
+import React, { useState } from 'react';
+import { Box, Typography, Button } from '@mui/material';
+import mapa from '@images/routing-pointa-ppointb.png';
+import DeleteConfirmationModal from './DeleteRouteConfirmation';
+import RouteDetailsModal from './RouteDetailsModal';
+import { useTranslation } from 'react-i18next';
+const RouteCard = ({route, onViewDetails, onDelete, googleMapsApiKey}) => {
+ const { t } = useTranslation();
+ const [deleteOpen, setDeleteOpen] = useState(false);
+ const [detailsOpen, setDetailsOpen] = useState(false);
+
+ const handleDelete = async() => {
+ try{
+ await onDelete(route.id);
+ }catch(err){
+ console.log("Delete unsuccessful");
+ }finally {
+ setDeleteOpen(false);
+ }
+ }
+ const handleViewDetails = () => {
+ try{
+ onViewDetails(route.id);
+ }catch(err){
+ console.log("Failed to open route details");
+ }
+ }
+ return (
+
+ {/* Top-center text */}
+
+ Ruta {route?.id}
+
+
+ {/* Buttons */}
+
+
+
+
+ setDetailsOpen(false)}
+ routeData={route}
+ />
+
+ setDeleteOpen(false)}
+ onConfirm={handleDelete}
+ />
+
+
+ );
+};
+
+export default RouteCard;
\ No newline at end of file
diff --git a/src/components/RouteDetailsModal.jsx b/src/components/RouteDetailsModal.jsx
new file mode 100644
index 0000000..d4d38ae
--- /dev/null
+++ b/src/components/RouteDetailsModal.jsx
@@ -0,0 +1,111 @@
+import React from 'react';
+import {
+ Box,
+ Modal,
+ Typography,
+ IconButton,
+ Divider,
+ List,
+ ListItem,
+ ListItemText,
+} from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import DirectionsIcon from '@mui/icons-material/Directions';
+import RouteMap from './RouteMap'; // prilagodi ako je u drugom folderu
+
+const style = {
+ position: 'absolute',
+ top: '50%',
+ left: '50%',
+ transform: 'translate(-50%, -50%)',
+ width: '90%',
+ height: '80%',
+ bgcolor: 'background.paper',
+ boxShadow: 24,
+ p: 2,
+ borderRadius: 2,
+ display: 'flex',
+ flexDirection: 'row',
+ overflow: 'hidden',
+};
+
+const RouteDetailsModal = ({ open, onClose, routeData }) => {
+ if (!routeData) return null;
+
+ const steps =
+ routeData.routeData?.data?.routes?.[0]?.legs?.[0]?.steps || [];
+
+ return (
+
+
+ {/* LEFT SIDE: Map */}
+
+
+
+
+ {/* RIGHT SIDE: Details */}
+
+ {/* Close Button */}
+
+
+
+
+
+ Route ID: {routeData.id}
+
+
+
+
+
+
+ Directions
+
+
+ {steps.length === 0 ? (
+ No directions available.
+ ) : (
+
+ {steps.map((step, index) => (
+
+
+ }
+ />
+
+ ))}
+
+ )}
+
+
+
+ );
+};
+
+export default RouteDetailsModal;
diff --git a/src/components/RouteDisplayModal.jsx b/src/components/RouteDisplayModal.jsx
new file mode 100644
index 0000000..12633b3
--- /dev/null
+++ b/src/components/RouteDisplayModal.jsx
@@ -0,0 +1,366 @@
+// RouteDisplayModal.js
+import React, { useState, useEffect, useCallback, useRef } from 'react';
+import PropTypes from 'prop-types';
+import {
+ GoogleMap,
+ LoadScript,
+ Polyline,
+ Marker,
+ InfoWindow,
+} from '@react-google-maps/api';
+import mapboxPolyline from '@mapbox/polyline';
+//import { useTranslation } from 'react-i18next';
+
+// Helper: Calculates geographical bounds for points (same as before)
+const getBoundingBox = (points) => {
+ if (!points || points.length === 0) return null;
+
+ // THIS LINE NEEDS `window.google` to be defined
+ const bounds = new google.maps.LatLngBounds();
+ points.forEach((point) => {
+ // THIS LINE ALSO NEEDS `window.google`
+ bounds.extend(new google.maps.LatLng(point.latitude, point.longitude));
+ });
+ return bounds;
+};
+
+/**
+ * @typedef {object} Point
+ * @property {number} latitude
+ * @property {number} longitude
+ * @property {string} [duration]
+ * @property {string} [address]
+ */
+
+/**
+ * RouteDisplayModal component.
+ * Displays a pre-calculated route on a Google Map within a modal-like view.
+ *
+ * @param {object} props
+ * @param {boolean} props.open - Whether the modal is visible.
+ * @param {function} props.onClose - Callback function to close the modal.
+ * @param {object} props.routeData - The Google Directions API route object (e.g., data.routes[0]).
+ * @param {string} props.googleMapsApiKey - Your Google Maps API Key.
+ * @returns {JSX.Element|null} The rendered modal component or null if not open.
+ */
+function RouteDisplayModal({ open, onClose, routeData, googleMapsApiKey }) {
+ const [routePath, setRoutePath] = useState([]);
+ const [waypoints, setWaypoints] = useState([]);
+ const [mapCenter, setMapCenter] = useState({
+ lat: 43.8665216,
+ lng: 18.3926784,
+ }); // Default
+ const [zoom, setZoom] = useState(10); // Default
+ const [activeMarker, setActiveMarker] = useState(null);
+ // const { t } = useTranslation();
+ const mapRef = useRef(null);
+ const t = (s) => s;
+
+ console.log(routeData);
+
+ /**
+ * Processes the provided route data to set map path and waypoints.
+ */
+ const processDisplayRouteData = useCallback(
+ (currentRouteData) => {
+ if (!currentRouteData) {
+ setRoutePath([]);
+ setWaypoints([]);
+ return;
+ }
+
+ let overviewPolyline = currentRouteData.overview_polyline?.points;
+ if (!overviewPolyline) {
+ overviewPolyline = currentRouteData.routes[0].overview_polyline?.points;
+ if (!overviewPolyline) {
+ console.error('Overview polyline missing from provided route data.');
+ setRoutePath([]);
+ setWaypoints([]);
+ return;
+ }
+ }
+
+ const decodedPath = mapboxPolyline
+ .decode(overviewPolyline)
+ .map(([lat, lng]) => ({ lat, lng }));
+ setRoutePath(decodedPath);
+
+ const newWaypoints = [];
+ let accumulatedTime = 0; // in seconds
+
+ if (currentRouteData.legs[0]?.start_location) {
+ newWaypoints.push({
+ latitude: currentRouteData.legs[0].start_location.lat,
+ longitude: currentRouteData.legs[0].start_location.lng,
+ address: currentRouteData.legs[0].start_address,
+ duration: t('Start Location'),
+ });
+ }
+
+ currentRouteData.legs.forEach((leg) => {
+ accumulatedTime += leg.duration.value;
+ let durationText = '';
+ if (accumulatedTime < 60) {
+ durationText = `< 1 ${t('min')}`;
+ } else if (accumulatedTime < 3600) {
+ durationText = `${Math.round(accumulatedTime / 60)} ${t('min')}`;
+ } else {
+ const hours = Math.floor(accumulatedTime / 3600);
+ const minutes = Math.round((accumulatedTime % 3600) / 60);
+ durationText = `${hours}h ${minutes}${t('min')}`;
+ }
+
+ newWaypoints.push({
+ latitude: leg.end_location.lat,
+ longitude: leg.end_location.lng,
+ address: leg.end_address,
+ duration: durationText,
+ });
+ });
+
+ setWaypoints(newWaypoints);
+
+ // Defer fitting bounds until map is loaded
+ if (
+ mapRef.current &&
+ (decodedPath.length > 0 || newWaypoints.length > 0)
+ ) {
+ const pointsToBound =
+ newWaypoints.length > 0
+ ? newWaypoints
+ : decodedPath.map((p) => ({ latitude: p.lat, longitude: p.lng }));
+ const bounds = getBoundingBox(pointsToBound);
+ if (bounds) {
+ mapRef.current.fitBounds(bounds);
+ // Optional: Get center and zoom after fitting bounds if needed for state,
+ // but usually fitBounds is enough.
+ // const newCenter = bounds.getCenter();
+ // setMapCenter({ lat: newCenter.lat(), lng: newCenter.lng() });
+ // setZoom(mapRef.current.getZoom());
+ }
+ } else if (decodedPath.length > 0) {
+ setMapCenter({ lat: decodedPath[0].lat, lng: decodedPath[0].lng });
+ setZoom(12);
+ }
+ },
+ [t]
+ ); // mapRef is not a dependency for useCallback here
+
+ useEffect(() => {
+ if (open && routeData) {
+ processDisplayRouteData(routeData);
+ } else if (!open) {
+ // Optionally clear when closed if desired, or let it persist
+ // setRoutePath([]);
+ // setWaypoints([]);
+ }
+ }, [open, routeData, processDisplayRouteData]);
+
+ const handleMapLoad = useCallback(
+ (map) => {
+ mapRef.current = map;
+ // If routeData is already present when map loads, fit bounds
+ if (open && routeData && (routePath.length > 0 || waypoints.length > 0)) {
+ const pointsToBound =
+ waypoints.length > 0
+ ? waypoints
+ : routePath.map((p) => ({ latitude: p.lat, longitude: p.lng }));
+ const bounds = getBoundingBox(pointsToBound);
+ if (bounds && mapRef.current) {
+ mapRef.current.fitBounds(bounds);
+ }
+ }
+ },
+ [open, routeData, routePath, waypoints]
+ ); // Add dependencies that affect bounding
+
+ const handleMarkerClick = (point) => {
+ setActiveMarker(point);
+ if (mapRef.current) {
+ // Center on marker click
+ mapRef.current.panTo({ lat: point.latitude, lng: point.longitude });
+ }
+ };
+
+ if (!open) {
+ return null;
+ }
+
+ if (!googleMapsApiKey) {
+ return (
+
+
+ {t('Error: Google Maps API Key is missing.')}
+
+
+
+ );
+ }
+ if (!routeData) {
+ return (
+
+
+ {t('No route data to display.')}
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ {t('Route Details')}
+
+
+ {/* */}
+
+ {routePath.length > 0 && (
+
+ )}
+ {waypoints.map((point, index) => (
+ handleMarkerClick(point)}
+ label={
+ index === 0
+ ? 'S'
+ : index === waypoints.length - 1
+ ? 'E'
+ : `${index}`
+ }
+ />
+ ))}
+ {activeMarker && (
+ setActiveMarker(null)}
+ >
+
+ {activeMarker.address}
+
+ {activeMarker.duration?.includes(t('Start')) ||
+ activeMarker.duration?.includes(t('End'))
+ ? activeMarker.duration
+ : `${t('ETA')}: ${activeMarker.duration || t('Unknown ETA')}`}
+
+
+
+ )}
+
+ {/* */}
+
+
+ {t('Total Distance')}:{' '}
+ {routeData?.legs?.reduce(
+ (acc, leg) => acc + leg.distance.value,
+ 0
+ ) / 1000}{' '}
+ km
+
+
+ {t('Total Duration')}:{' '}
+ {Math.round(
+ routeData?.legs?.reduce(
+ (acc, leg) => acc + leg.duration.value,
+ 0
+ ) / 60
+ )}{' '}
+ {t('min')}
+
+
+
+
+
+ );
+}
+
+RouteDisplayModal.propTypes = {
+ open: PropTypes.bool.isRequired,
+ onClose: PropTypes.func.isRequired,
+ routeData: PropTypes.object, // Can be null if no route is selected yet
+ googleMapsApiKey: PropTypes.string.isRequired,
+};
+
+const styles = {
+ modalOverlay: {
+ position: 'fixed',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ backgroundColor: 'rgba(0, 0, 0, 0.5)',
+ display: 'flex',
+ justifyContent: 'center',
+ alignItems: 'center',
+ zIndex: 1000,
+ },
+ modalContent: {
+ backgroundColor: 'white',
+ padding: '20px',
+ borderRadius: '8px',
+ width: '90%',
+ maxWidth: '800px', // Max width for the modal
+ maxHeight: '90vh',
+ overflowY: 'auto',
+ boxShadow: '0 4px 6px rgba(0,0,0,0.1)',
+ },
+ mapContainer: {
+ width: '100%',
+ height: '400px', // Or any appropriate height for the modal map
+ marginBottom: '10px',
+ },
+ closeButton: {
+ background: 'none',
+ border: 'none',
+ fontSize: '1.5rem',
+ cursor: 'pointer',
+ padding: '5px',
+ lineHeight: '1',
+ },
+ summary: {
+ marginTop: '15px',
+ paddingTop: '10px',
+ borderTop: '1px solid #eee',
+ },
+};
+
+export default RouteDisplayModal;
diff --git a/src/components/RouteMap.jsx b/src/components/RouteMap.jsx
new file mode 100644
index 0000000..e772ec8
--- /dev/null
+++ b/src/components/RouteMap.jsx
@@ -0,0 +1,217 @@
+// src/components/RouteMap.jsx
+import React, { useState, useEffect, useCallback, useMemo } from 'react';
+import {
+ GoogleMap,
+ useJsApiLoader,
+ Polyline,
+ MarkerF,
+} from '@react-google-maps/api';
+import polylineUtil from '@mapbox/polyline';
+import { CircularProgress, Alert, Box, Typography } from '@mui/material';
+
+const MAP_LIBRARIES = ['geometry', 'places'];
+
+const RouteMap = ({ backendResponse }) => {
+ const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
+
+ const { isLoaded, loadError } = useJsApiLoader({
+ googleMapsApiKey: apiKey,
+ libraries: MAP_LIBRARIES,
+ // id: 'google-map-script', // Optional: useful for multiple maps or specific loading strategies
+ });
+
+ const [map, setMap] = useState(null);
+ const [decodedPath, setDecodedPath] = useState([]);
+ const [startLocation, setStartLocation] = useState(null);
+ const [endLocation, setEndLocation] = useState(null);
+ const [mapBoundsObject, setMapBoundsObject] = useState(null); // Stores the actual LatLngBounds object
+
+ // Effect to process backendResponse and create map elements
+ useEffect(() => {
+ // Only proceed if the API is loaded and we have valid backend data
+ if (!isLoaded || !backendResponse?.routeData?.data?.routes?.[0]) {
+ // Clear out data if not ready or no valid response
+ setDecodedPath([]);
+ setStartLocation(null);
+ setEndLocation(null);
+ setMapBoundsObject(null);
+ return;
+ }
+
+ const route = backendResponse.routeData.data.routes[0];
+
+ // Decode polyline
+ if (route.overview_polyline?.points) {
+ try {
+ const decoded = polylineUtil
+ .decode(route.overview_polyline.points)
+ .map((point) => ({
+ lat: point[0],
+ lng: point[1],
+ }));
+ setDecodedPath(decoded);
+ } catch (e) {
+ console.error('Error decoding polyline:', e);
+ setDecodedPath([]);
+ }
+ } else {
+ setDecodedPath([]);
+ }
+
+ // Set start/end locations for markers
+ if (route.legs?.[0]) {
+ setStartLocation(route.legs[0].start_location);
+ setEndLocation(route.legs[0].end_location);
+ } else {
+ setStartLocation(null);
+ setEndLocation(null);
+ }
+
+ // Create LatLngBounds object (THIS IS LIKELY WHERE THE ERROR WAS)
+ // Now this block is guarded by `isLoaded`
+ if (route.bounds && window.google && window.google.maps) {
+ // Double check window.google just in case, though isLoaded should cover it
+ try {
+ const newBounds = new window.google.maps.LatLngBounds(
+ new window.google.maps.LatLng(
+ route.bounds.southwest.lat,
+ route.bounds.southwest.lng
+ ),
+ new window.google.maps.LatLng(
+ route.bounds.northeast.lat,
+ route.bounds.northeast.lng
+ )
+ );
+ setMapBoundsObject(newBounds);
+ } catch (e) {
+ console.error(
+ 'Error creating LatLngBounds from route.bounds:',
+ e,
+ route.bounds
+ );
+ setMapBoundsObject(null); // Fallback if creation fails
+ }
+ } else {
+ setMapBoundsObject(null); // If no route.bounds, clear any existing mapBoundsObject
+ }
+ }, [backendResponse, isLoaded]); // Key dependencies: backendResponse and isLoaded
+
+ const onMapLoad = useCallback((mapInstance) => {
+ setMap(mapInstance);
+ }, []);
+
+ // Effect to fit bounds once map is loaded and bounds/path are ready
+ useEffect(() => {
+ if (!map || !isLoaded) return; // Ensure map and API are ready
+
+ if (mapBoundsObject && !mapBoundsObject.isEmpty()) {
+ map.fitBounds(mapBoundsObject);
+ } else if (decodedPath.length > 0) {
+ // Fallback to fitting bounds from the decoded path
+ console.log(
+ 'Fitting bounds to decoded path as mapBoundsObject not available or empty.'
+ );
+ try {
+ const pathBounds = new window.google.maps.LatLngBounds();
+ decodedPath.forEach((point) => {
+ if (
+ point &&
+ typeof point.lat === 'number' &&
+ typeof point.lng === 'number'
+ ) {
+ pathBounds.extend(
+ new window.google.maps.LatLng(point.lat, point.lng)
+ );
+ } else {
+ console.warn('Invalid point in decodedPath:', point);
+ }
+ });
+ if (!pathBounds.isEmpty()) {
+ map.fitBounds(pathBounds);
+ } else {
+ console.warn('Path bounds are empty, cannot fit.');
+ }
+ } catch (e) {
+ console.error(
+ 'Error creating LatLngBounds from decodedPath:',
+ e,
+ decodedPath
+ );
+ }
+ }
+ }, [map, mapBoundsObject, decodedPath, isLoaded]); // Key dependencies
+
+ const mapContainerStyle = {
+ width: '100%',
+ height: '100%', // Crucial: map needs explicit height from parent
+ };
+
+ const defaultCenter = useMemo(() => ({ lat: 43.8563, lng: 18.4131 }), []);
+
+ if (loadError) {
+ console.error('Google Maps API load error:', loadError);
+ return (
+
+ Error loading Google Maps: {loadError.message}
+
+ );
+ }
+
+ if (!apiKey) {
+ return (
+ Error: Google Maps API Key is missing.
+ );
+ }
+
+ // Show loading spinner while API is loading
+ if (!isLoaded) {
+ return (
+
+
+ Loading Map...
+
+ );
+ }
+
+ // API is loaded, now render the map
+ return (
+ setMap(null)} // Good practice for cleanup
+ options={
+ {
+ // streetViewControl: false,
+ // mapTypeControl: false,
+ // You can add more options here
+ }
+ }
+ >
+ {decodedPath.length > 0 && (
+
+ )}
+ {startLocation && (
+
+ )}
+ {endLocation && }
+
+ );
+};
+
+export default RouteMap;
diff --git a/src/components/SalesChart.jsx b/src/components/SalesChart.jsx
new file mode 100644
index 0000000..2713220
--- /dev/null
+++ b/src/components/SalesChart.jsx
@@ -0,0 +1,305 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Paper,
+ Box,
+ Typography,
+ IconButton,
+ Menu,
+ MenuItem,
+ Stack,
+ useTheme,
+} from '@mui/material';
+import FilterListIcon from '@mui/icons-material/FilterList';
+// Using ShoppingCartIcon from develop as it's generic for product sales
+import ShoppingCartIcon from '@mui/icons-material/ShoppingCart';
+// API imports from develop
+import {
+ apiGetAllAdsAsync,
+ apiGetAllStoresAsync,
+ apiGetStoreProductsAsync,
+} from '../api/api.js';
+
+function SalesChart() {
+ const theme = useTheme();
+ const [filterType, setFilterType] = useState('topRated'); // 'topRated' or 'lowestRated'
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [productSalesData, setProductSalesData] = useState([]); // Changed from productData to be more specific
+ const open = Boolean(anchorEl);
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ // Fetch all ads
+ const adsResponse = await apiGetAllAdsAsync();
+ const ads =
+ adsResponse && Array.isArray(adsResponse.data)
+ ? adsResponse.data
+ : [];
+
+ // Fetch all stores
+ const storesResponse = await apiGetAllStoresAsync();
+ const stores = Array.isArray(storesResponse) ? storesResponse : [];
+
+ // Create a map of all products from all stores
+ const allProductsMap = {};
+ for (const store of stores) {
+ if (store && store.id) {
+ const productsResponse = await apiGetStoreProductsAsync(store.id);
+ const storeProducts =
+ productsResponse && Array.isArray(productsResponse.data)
+ ? productsResponse.data
+ : [];
+ for (const product of storeProducts) {
+ if (product && product.id && !allProductsMap[product.id]) {
+ allProductsMap[product.id] = {
+ id: product.id,
+ name: product.name || `Product ${product.id}`,
+ // imageUrl: product.imageUrl || 'https://via.placeholder.com/40', // If you have image URLs
+ // Placeholder for product-specific icon/color if needed later
+ // icon: ShoppingCartIcon,
+ // color: theme.palette.text.secondary,
+ clicks: 0,
+ conversions: 0,
+ revenue: 0,
+ };
+ }
+ }
+ }
+ }
+
+ // Process all ads to aggregate sales data per product
+ for (const ad of ads) {
+ if (ad && Array.isArray(ad.adData)) {
+ for (const adDataItem of ad.adData) {
+ if (
+ adDataItem &&
+ adDataItem.productId &&
+ allProductsMap[adDataItem.productId]
+ ) {
+ const productEntry = allProductsMap[adDataItem.productId];
+ productEntry.clicks += ad.clicks || 0;
+ productEntry.conversions += ad.conversions || 0;
+ productEntry.revenue +=
+ (ad.conversions || 0) * (ad.conversionPrice || 0);
+ }
+ }
+ }
+ }
+
+ // Sort products based on filterType (revenue)
+ let sortedProductsArray = Object.values(allProductsMap);
+
+ if (filterType === 'topRated') {
+ sortedProductsArray.sort((a, b) => b.revenue - a.revenue);
+ } else {
+ // 'lowestRated'
+ // Filter out products with zero revenue for "lowest rated" to make it meaningful
+ sortedProductsArray = sortedProductsArray.filter(
+ (p) => p.revenue > 0
+ );
+ sortedProductsArray.sort((a, b) => a.revenue - b.revenue);
+ }
+
+ // Limit to top/lowest 4 products (or adjust as needed)
+ setProductSalesData(sortedProductsArray.slice(0, 4));
+ } catch (error) {
+ console.error('Error fetching product sales data:', error);
+ setProductSalesData([]); // Reset on error
+ }
+ };
+
+ fetchData();
+ }, [filterType]); // Re-fetch when filterType changes
+
+ const handleFilterClick = (event) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const handleFilterChange = (type) => {
+ setFilterType(type);
+ handleClose(); // Also close the menu
+ };
+
+ const totalRevenueAllDisplayed = productSalesData.reduce(
+ (sum, p) => sum + p.revenue,
+ 0
+ );
+
+ return (
+
+
+ {/* Filter Button and Menu - structure from develop is fine */}
+
+
+
+ Filters
+
+
+
+
+
+
+
+
+ {productSalesData.length === 0 && (
+
+ No product sales data to display for this filter.
+
+ )}
+ {productSalesData.map((item) => {
+ // Dynamic percentage calculation from develop
+ const percentage =
+ totalRevenueAllDisplayed > 0
+ ? ((item.revenue / totalRevenueAllDisplayed) * 100).toFixed(1)
+ : '0.0';
+
+ return (
+
+
+ {/* Using generic ShoppingCartIcon from develop.
+ If item had specific icon data, could use React.createElement here. */}
+
+
+
+
+
+ {item.name.length > 16
+ ? `${item.name.substring(0, 16)}...`
+ : item.name}{' '}
+
+
+
+
+
+ ${item.revenue.toLocaleString()}
+
+
+ {percentage}%
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+export default SalesChart;
diff --git a/src/components/SearchBar.jsx b/src/components/SearchBar.jsx
new file mode 100644
index 0000000..330c63d
--- /dev/null
+++ b/src/components/SearchBar.jsx
@@ -0,0 +1,14 @@
+import { TextField } from "@mui/material";
+
+export default function SearchBar({ value, onChange }) {
+ return (
+
+ );
+}
diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx
new file mode 100644
index 0000000..f42a689
--- /dev/null
+++ b/src/components/Sidebar.jsx
@@ -0,0 +1,214 @@
+import React from 'react';
+import icon from '@icons/admin.svg';
+import {
+ Box,
+ Avatar,
+ Typography,
+ IconButton,
+ Divider,
+ Button,
+} from '@mui/material';
+import { HiOutlineBell } from 'react-icons/hi';
+import { HiOutlineUserGroup } from 'react-icons/hi';
+import {
+ sidebarContainer,
+ profileBox,
+ navItem,
+ iconBox,
+ footerBox,
+} from './SidebarStyles';
+import AdminSearchBar from '@components/AdminSearchBar';
+import ThemeToggle from '@components/ThemeToggle';
+import { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { usePendingUsers } from '@context/PendingUsersContext';
+import LogoutIcon from '@mui/icons-material/Logout';
+import { FiShoppingBag } from 'react-icons/fi';
+import { FiGrid } from 'react-icons/fi';
+import { FiClipboard } from 'react-icons/fi';
+import { FiBarChart2 } from 'react-icons/fi';
+import { HiOutlineMegaphone } from 'react-icons/hi2';
+import { FiMessageCircle } from 'react-icons/fi';
+import { FaRoute } from 'react-icons/fa';
+import { FaLanguage } from 'react-icons/fa';
+import { useTranslation } from 'react-i18next';
+
+
+const Sidebar = () => {
+ const { t } = useTranslation();
+ const navigate = useNavigate();
+ const { pendingUsers } = usePendingUsers();
+ const menuItems = [
+ {
+ icon: ,
+ label: t('common.analytics'),
+ path: '/analytics',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.users'),
+ path: '/users',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.requests'),
+ path: '/requests',
+ badge: pendingUsers.length,
+ },
+ {
+ icon: ,
+ label: t('common.stores'),
+ path: '/stores',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.categories'),
+ path: '/categories',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.orders'),
+ path: '/orders',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.advertisements'),
+ path: '/ads',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.chat'),
+ path: '/chat',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.routes'),
+ path: '/routes',
+ badge: null,
+ },
+ {
+ icon: ,
+ label: t('common.languages'),
+ path: '/languages',
+ badge: null,
+ },
+ ];
+ const [isDark, setIsDark] = useState(false);
+ const toggleTheme = () => setIsDark(!isDark);
+
+ const handleLogout = () => {
+ console.log('Logging out...');
+
+ // 1. Clear authentication artifacts from local storage
+ // (Add/remove items based on what you actually store)
+ localStorage.removeItem('token');
+ localStorage.removeItem('auth'); // From your AppRoutes example
+ // localStorage.removeItem('user'); // Example: if you store user info
+
+ // 2. Redirect to the login page
+ // 'replace: true' prevents the user from navigating back to the protected page
+ navigate('/login', { replace: true });
+
+ // Optional: Force reload if state isn't clearing properly (useNavigate is usually sufficient)
+ // window.location.reload();
+
+ // Optional: Call a backend logout endpoint if needed
+ // try {
+ // await axios.post('/api/auth/logout');
+ // } catch (error) {
+ // console.error("Backend logout failed:", error);
+ // }
+
+ // Optional: Clear any global state (Context, Redux, Zustand) if necessary
+ // authContext.logout();
+ };
+
+ return (
+
+
+
+
+ Bazaar
+
+ {t('common.administrator')}
+
+
+
+
+
+
+ {/* Search */}
+ {/* Menu */}
+ {menuItems.map((item, index) => (
+ navigate(item.path)}
+ style={{ cursor: 'pointer' }}
+ >
+ {item.icon}
+ {item.label}
+ {item.badge && (
+
+ {item.badge}
+
+ )}
+
+ ))}
+
+
+
+ {
+ /* Footer toggle */
+ } // Optional icon
+ >
+ {t('common.logout')}
+
+ }
+
+
+ );
+};
+
+export default Sidebar;
diff --git a/src/components/SidebarStyles.jsx b/src/components/SidebarStyles.jsx
new file mode 100644
index 0000000..8ce94a6
--- /dev/null
+++ b/src/components/SidebarStyles.jsx
@@ -0,0 +1,47 @@
+export const sidebarContainer = {
+ position: "fixed", // Zalijepi za lijevu stranu
+ top: 0,
+ left: 0,
+ height: "100vh", // Cijela visina prozora
+ width: 260,
+ backgroundColor: "#f9f9f9",
+ display: "flex",
+ flexDirection: "column",
+ justifyContent: "space-between",
+ padding: "24px 16px",
+ boxShadow: "2px 0 8px rgba(0,0,0,0.05)",
+ zIndex: 1000, // Iznad ostalog sadržaja
+};
+
+export const profileBox = {
+ display: "flex",
+ alignItems: "center",
+ gap: 1.5,
+ mb: 3,
+};
+
+export const navItem = {
+ display: "flex",
+ alignItems: "center",
+ gap: 1.5,
+ p: 1,
+ borderRadius: 2,
+ cursor: "pointer",
+ "&:hover": {
+ backgroundColor: "#eef2f5",
+ },
+ mb: 1,
+};
+
+export const iconBox = {
+ fontSize: 20,
+ color: "#555",
+};
+
+export const footerBox = {
+ display: "flex",
+ justifyContent: "center",
+ alignItems: "center",
+ mt: "auto",
+ gap: 1,
+};
diff --git a/src/components/SocialLoginButton.jsx b/src/components/SocialLoginButton.jsx
new file mode 100644
index 0000000..8344fb8
--- /dev/null
+++ b/src/components/SocialLoginButton.jsx
@@ -0,0 +1,13 @@
+import React from 'react';
+import Button from '@mui/material/Button';
+import socialButtonStyle from './SocialLoginButtonStyles';
+
+const SocialLoginButton = ({ icon, label, onClick }) => {
+ return (
+
+ );
+};
+
+export default SocialLoginButton;
diff --git a/src/components/SocialLoginButtonStyles.jsx b/src/components/SocialLoginButtonStyles.jsx
new file mode 100644
index 0000000..de6aa0f
--- /dev/null
+++ b/src/components/SocialLoginButtonStyles.jsx
@@ -0,0 +1,31 @@
+const socialButtonStyle = (theme) => ({
+ borderRadius: '12px',
+ paddingY: 1.2,
+ paddingX: 3,
+ fontWeight: 500,
+ minWidth: 150,
+ justifyContent: 'flex-start',
+ gap: 1.5,
+ borderColor: theme.palette.primary.light,
+ color: theme.palette.primary.main,
+ transition: 'all 0.3s ease',
+
+ '& .MuiButton-startIcon': {
+ margin: 0,
+ },
+
+ '&:hover': {
+ backgroundColor: '#f8f8f8',
+ borderColor: theme.palette.primary.main,
+ transform: 'translateY(-2px)',
+ boxShadow: `0 6px 12px ${theme.palette.primary.light}`,
+ },
+
+ '&:focus': {
+ outline: 'none',
+ boxShadow: `0 0 0 3px ${theme.palette.primary.light}`,
+ },
+ });
+
+ export default socialButtonStyle;
+
\ No newline at end of file
diff --git a/src/components/StoreCard.jsx b/src/components/StoreCard.jsx
new file mode 100644
index 0000000..dab9153
--- /dev/null
+++ b/src/components/StoreCard.jsx
@@ -0,0 +1,418 @@
+import React, { useState, useEffect, useRef } from 'react';
+import {
+ Box,
+ Typography,
+ Button,
+ IconButton,
+ Avatar,
+ Menu,
+ MenuItem,
+} from '@mui/material';
+import StoreIcon from '@mui/icons-material/Store';
+import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
+import { FiEdit2, FiTrash } from 'react-icons/fi';
+import { FaPaperclip } from 'react-icons/fa6';
+import {
+ apiUpdateStoreAsync,
+ apiDeleteStoreAsync,
+ apiGetStoreCategoriesAsync,
+ apiExportProductsToCSVAsync,
+ apiExportProductsToExcelAsync,
+ apiCreateProductAsync,
+ apiGetMonthlyStoreRevenueAsync
+} from '@api/api';
+import AddProductModal from '@components/NewProductModal';
+import LocationOnIcon from '@mui/icons-material/LocationOn';
+import EditStoreModal from '@components/EditStoreModal';
+import ConfirmDeleteStoreModal from '@components/ConfirmDeleteStoreModal';
+import StoreProductsList from '@components/StoreProductsList';
+import * as XLSX from 'xlsx';
+import { apiGetStoreByIdAsync } from '../api/api';
+import { useTranslation } from 'react-i18next';
+
+
+const StoreCard = ({ store }) => {
+ const { t } = useTranslation();
+ const [anchorEl, setAnchorEl] = useState(null);
+ const [menuAnchor, setMenuAnchor] = useState(null);
+ const [storeData, setStoreData] = useState(store);
+ const [isOnline, setIsOnline] = useState(storeData.isActive);
+ const [openModal, setOpenModal] = useState(false);
+ const [openEditModal, setOpenEditModal] = useState(false);
+ const [openDeleteModal, setOpenDeleteModal] = useState(false);
+ const [categories, setCategories] = useState([]);
+ const [updating, setUpdating] = useState(false);
+ const fileInputRef = useRef();
+ const [parsedProducts, setParsedProducts] = useState([]);
+ const [revenue, setRevenue] = useState(0);
+ const openStatus = Boolean(anchorEl);
+ const openMenu = Boolean(menuAnchor);
+
+ useEffect(() => {
+ apiGetStoreCategoriesAsync().then(setCategories);
+ const fetchRevenue = async () => {
+ try {
+ const rez = await apiGetMonthlyStoreRevenueAsync(storeData.id);
+ console.log(rez.taxedIncome); // ✅ This will now work
+ setRevenue(rez);
+ } catch (error) {
+ console.error("Failed to fetch revenue:", error);
+ }
+ };
+
+ fetchRevenue();
+ }, []);
+
+ const handleStatusClick = (e) => setAnchorEl(e.currentTarget);
+ const handleStatusChange = async (newStatus) => {
+ setUpdating(true);
+ const matchedCategory = categories.find(
+ (cat) => cat.name === storeData.categoryName
+ );
+ if (!matchedCategory) return;
+
+ const updatedStore = {
+ ...storeData,
+ isActive: newStatus,
+ categoryId: matchedCategory.id,
+ };
+
+ const res = await apiUpdateStoreAsync(updatedStore);
+ if (res?.success || res?.status === 201) setIsOnline(newStatus);
+ setUpdating(false);
+ setAnchorEl(null);
+ };
+
+ const handleExportCSV = async () => {
+ const response = await apiExportProductsToCSVAsync(storeData.id);
+ const url = window.URL.createObjectURL(new Blob([response.data]));
+ const link = document.createElement('a');
+ link.href = url;
+ link.setAttribute('download', 'Proizvodi.csv');
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ const handleExportExcel = async () => {
+ const response = await apiExportProductsToExcelAsync(storeData.id);
+ console.log('PREOVJERA', response.data);
+
+ const blob = response.data;
+ const url = window.URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.setAttribute('download', 'Proizvodi.xlsx');
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ const handleMenuClick = (e) => setMenuAnchor(e.currentTarget);
+ const handleMenuClose = () => setMenuAnchor(null);
+
+ const handleFileUpload = (e) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const fileName = file.name.toLowerCase();
+ const isCSV = fileName.endsWith('.csv');
+ const reader = new FileReader();
+
+ reader.onload = (evt) => {
+ const fileContent = evt.target.result;
+ let workbook;
+
+ if (isCSV) {
+ workbook = XLSX.read(fileContent, { type: 'string' });
+ } else {
+ workbook = XLSX.read(fileContent, { type: 'binary' });
+ }
+
+ const sheet = workbook.Sheets[workbook.SheetNames[0]];
+ const jsonData = XLSX.utils.sheet_to_json(sheet);
+ setParsedProducts(jsonData);
+ handleBulkCreate(jsonData);
+ };
+
+ if (isCSV) {
+ reader.readAsText(file); // CSV kao tekst
+ } else {
+ reader.readAsBinaryString(file); // Excel kao binarni
+ }
+ };
+
+ const handleBulkCreate = async (products) => {
+ let success = 0;
+ let fail = 0;
+
+ for (const product of products) {
+ console.log('Creating product:', product);
+ try {
+ const res = await apiCreateProductAsync({
+ ...product,
+ storeId: storeData.id,
+ });
+ console.log('Response from apiCreateProductAsync:', res);
+
+ // Ovo je sad ispravno
+ res?.status === 201 ? success++ : fail++;
+ } catch (error) {
+ console.error('Error in bulk create:', error);
+ fail++;
+ }
+ }
+ window.location.reload();
+
+ console.log(`✅ ${success} created, ❌ ${fail} failed`);
+ };
+
+ const onUpdate = async (targetstore) =>{
+ try{
+ const rez = await apiUpdateStoreAsync(targetstore);
+ const newstore = await apiGetStoreByIdAsync(targetstore.id);
+ setStoreData(newstore);
+ }catch(err){
+ console.log(err);
+ }
+
+ };
+ return (
+ <>
+
+ {/* Status & Delete */}
+
+ setOpenDeleteModal(true)} sx={{ p: 0.5 }}>
+
+
+
+
+
+
+
+
+
+ {/* Header */}
+
+
+
+
+
+
+ {storeData.name}
+ setOpenEditModal(true)}
+ sx={{ p: 0, opacity: 0, transition: 'opacity 0.2s' }}
+ >
+
+
+
+
+
+
+ {storeData.address}
+
+
+
+
+
+ {/* Description */}
+
+ {storeData.description}
+
+
+
+ {t('common.tax')}: {(storeData.tax*100).toFixed(2)}
+
+
+
+ {t('common.totalMonthlyIncome')}: {revenue.totalIncome}
+
+
+
+ {t('common.taxedMonthlyIncome')}: {revenue.taxedIncome}
+
+
+ {/* Buttons */}
+
+
+
+
+
+
+
+
+
+
+
+ {/* Products List */}
+
+
+
+ setOpenModal(false)}
+ storeID={storeData.id}
+ />
+ setOpenEditModal(false)}
+ store={store}
+ onStoreUpdated={onUpdate}
+ />
+ setOpenDeleteModal(false)}
+ storeName={storeData.name}
+ onConfirm={async () => {
+ const res = await apiDeleteStoreAsync(storeData.id);
+ if (res.success) window.location.reload();
+ }}
+ />
+ >
+ );
+};
+
+export default StoreCard;
diff --git a/src/components/StoreEarningsTable.jsx b/src/components/StoreEarningsTable.jsx
new file mode 100644
index 0000000..64cd179
--- /dev/null
+++ b/src/components/StoreEarningsTable.jsx
@@ -0,0 +1,92 @@
+import React, { useState, useMemo } from 'react';
+import {
+ Table, TableHead, TableRow, TableCell, TableBody,
+ TablePagination, TableSortLabel, Paper, Box
+} from '@mui/material';
+import { useTranslation } from 'react-i18next';
+
+const StoreEarningsTable = ({ data }) => {
+ const { t } = useTranslation();
+ const [page, setPage] = useState(0);
+ const rowsPerPage = 5;
+ const [orderBy, setOrderBy] = useState('storeRevenue');
+ const [order, setOrder] = useState('desc');
+
+ const handleSort = (property) => {
+ const isAsc = orderBy === property && order === 'asc';
+ setOrder(isAsc ? 'desc' : 'asc');
+ setOrderBy(property);
+ };
+
+ const sortedData = useMemo(() => {
+ return [...data].sort((a, b) =>
+ (order === 'asc' ? a[orderBy] - b[orderBy] : b[orderBy] - a[orderBy])
+ );
+ }, [data, order, orderBy]);
+
+ const paginatedData = sortedData.slice(page * rowsPerPage, (page + 1) * rowsPerPage);
+
+ return (
+
+
+
+
+
+
+ {t('common.storeName')}
+
+
+ handleSort('storeRevenue')}
+ >
+ {t('analytics.storeRevenue')}
+
+
+
+ handleSort('adminProfit')}
+ >
+ {t('analytics.adminProfit')}
+
+
+
+ handleSort('taxRate')}
+ >
+ {t('analytics.taxRate')}
+
+
+
+
+
+ {paginatedData.map((row) => (
+
+ {row.name}
+ {(row.storeRevenue ?? 0).toFixed(2)} $
+ {(row.adminProfit ?? 0).toFixed(2)} $
+ {(row.taxRate ?? 0).toFixed(2)} %
+
+
+ ))}
+
+
+
+ setPage(newPage)}
+ />
+
+ );
+};
+
+export default StoreEarningsTable;
diff --git a/src/components/StoreProductsList.jsx b/src/components/StoreProductsList.jsx
new file mode 100644
index 0000000..5a967fa
--- /dev/null
+++ b/src/components/StoreProductsList.jsx
@@ -0,0 +1,210 @@
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, IconButton, Tooltip } from '@mui/material';
+import { FiEdit2, FiTrash } from 'react-icons/fi';
+import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
+import {
+ apiGetStoreProductsAsync,
+ apiDeleteProductAsync,
+ apiUpdateProductAsync,
+} from '@api/api';
+import EditProductModal from './EditProductModal';
+import ProductDetailsModal from './ProductDetailsModal';
+import { useTranslation } from 'react-i18next';
+
+const StoreProductsList = ({ storeId }) => {
+ const [products, setProducts] = useState([]);
+ const [openEditModal, setOpenEditModal] = useState(false);
+ const [openDetailsModal, setOpenDetailsModal] = useState(false);
+ const [selectedProduct, setSelectedProduct] = useState(null);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ const fetchProducts = async () => {
+ const response = await apiGetStoreProductsAsync(storeId);
+ if (response.status === 200) {
+ setProducts(response.data);
+ }
+ };
+ fetchProducts();
+ }, [storeId]);
+
+ const handleEditClick = (product, e) => {
+ e.stopPropagation();
+ setSelectedProduct(product);
+ setOpenEditModal(true);
+ };
+
+ const handleDeleteClick = async (productId, e) => {
+ e.stopPropagation();
+ const response = await apiDeleteProductAsync(productId);
+ if (response.status === 204) {
+ setProducts((prev) => prev.filter((p) => p.id !== productId));
+ }
+ };
+
+ const handleStatusClick = async (product, e) => {
+ e.stopPropagation();
+ console.log('🟡 Selected product before toggle:', product);
+
+ const updatedProduct = {
+ id: product.id,
+ name: product.name,
+ retailPrice: Number(product.retailPrice ?? product.price ?? 0),
+ wholesaleThreshold: 0,
+ wholesalePrice: Number(product.wholesalePrice ?? product.price ?? 0),
+ productCategoryId:
+ product.productCategory?.id ?? product.productCategoryId ?? 1,
+ weight: product.weight ?? 0,
+ volume: product.volume ?? 0,
+ weightUnit: product.weightUnit ?? 'kg',
+ volumeUnit: product.volumeUnit ?? 'L',
+ storeId: product.storeId,
+ isActive: !product.isActive,
+ files: product.photos ?? [],
+ };
+
+ console.log('📦 Sending updated product to API:', updatedProduct);
+
+ const response = await apiUpdateProductAsync(updatedProduct);
+ if (response.status >= 200 && response.status < 300) {
+ setProducts((prev) =>
+ prev.map((p) =>
+ p.id === product.id ? { ...p, isActive: !p.isActive } : p
+ )
+ );
+ }
+ };
+
+ const renderPlaceholderItems = () => {
+ const itemHeight = 40;
+ const minItems = 3;
+ const placeholdersNeeded = Math.max(0, minItems - products.length);
+
+ return Array(placeholdersNeeded)
+ .fill(null)
+ .map((_, index) => (
+
+ ));
+ };
+
+ return (
+
+
+ {t('common.products')}
+
+
+ {products.map((product) => (
+ {
+ setSelectedProduct(product);
+ setOpenDetailsModal(true);
+ }}
+ sx={{
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ p: 1,
+ height: '40px',
+ cursor: 'pointer',
+ '&:hover': {
+ backgroundColor: '#f5f5f5',
+ '& .edit-icon': { opacity: 1 },
+ },
+ }}
+ >
+
+
+ handleStatusClick(product, e)}
+ sx={{ p: 0 }}
+ >
+
+
+
+
+ {product.name}
+
+
+
+ handleEditClick(product, e)}
+ >
+
+
+ handleDeleteClick(product.id, e)}
+ >
+
+
+
+
+ ))}
+ {renderPlaceholderItems()}
+
+
+ setOpenEditModal(false)}
+ product={selectedProduct}
+ onSave={(updatedProduct) => {
+ setProducts((prev) =>
+ prev.map((p) => (p.id === updatedProduct.id ? updatedProduct : p))
+ );
+ setOpenEditModal(false);
+ }}
+ />
+
+ setOpenDetailsModal(false)}
+ product={selectedProduct}
+ />
+
+ );
+};
+
+export default StoreProductsList;
diff --git a/src/components/SuccessMessage.jsx b/src/components/SuccessMessage.jsx
new file mode 100644
index 0000000..0f85190
--- /dev/null
+++ b/src/components/SuccessMessage.jsx
@@ -0,0 +1,39 @@
+import React from "react";
+import { Modal, Box, Typography } from "@mui/material";
+import CheckCircleIcon from "@mui/icons-material/CheckCircle";
+import ErrorIcon from "@mui/icons-material/Error";
+
+const SuccessMessage = ({ open, onClose, isSuccess, message }) => {
+ const Icon = isSuccess ? CheckCircleIcon : ErrorIcon;
+ const iconColor = isSuccess ? "#4caf50" : "#f44336";
+ const title = isSuccess ? "Success" : "Error";
+
+ return (
+
+
+
+
+ {title}
+
+
+ {message}
+
+
+
+ );
+};
+
+export default SuccessMessage;
diff --git a/src/components/ThemeToggle.jsx b/src/components/ThemeToggle.jsx
new file mode 100644
index 0000000..53f2cb1
--- /dev/null
+++ b/src/components/ThemeToggle.jsx
@@ -0,0 +1,75 @@
+import React from "react";
+import { Box, Typography } from "@mui/material";
+import { Sun, Moon } from "lucide-react";
+
+const ThemeToggle = ({ isDark, toggleTheme }) => {
+ return (
+
+ {/* Tekst: Light */}
+
+ Light
+
+
+ {/* Tekst: Dark */}
+
+ Dark
+
+
+ {/* Klizeća ikona */}
+
+ {isDark ? (
+
+ ) : (
+
+ )}
+
+
+ );
+};
+
+export default ThemeToggle;
diff --git a/src/components/TicketCard.jsx b/src/components/TicketCard.jsx
new file mode 100644
index 0000000..b48c8fa
--- /dev/null
+++ b/src/components/TicketCard.jsx
@@ -0,0 +1,129 @@
+// @components/TicketCard.jsx
+import React from 'react';
+import {
+ Card,
+ CardContent,
+ Typography,
+ Box,
+ IconButton,
+ Chip,
+ Stack,
+} from '@mui/material';
+import ChatIcon from '@mui/icons-material/Chat';
+import DeleteIcon from '@mui/icons-material/Delete';
+import CheckCircleIcon from '@mui/icons-material/CheckCircle';
+import RadioButtonUncheckedIcon from '@mui/icons-material/RadioButtonUnchecked';
+
+export default function TicketCard({
+ ticket,
+ selected,
+ unlocked,
+ onClick,
+ onOpenChat,
+ onDelete,
+ onResolve,
+}) {
+ const { title, description, createdAt, status } = ticket;
+
+ const chatIconColor =
+ status === 'Requested'
+ ? '#bdbdbd'
+ : status === 'Open'
+ ? '#43a047'
+ : '#bdbdbd';
+
+ const statusColor =
+ status === 'Requested'
+ ? 'warning'
+ : status === 'Open'
+ ? '#4CAF50'
+ : status === 'Resolved'
+ ? '#2196F3'
+ : 'default';
+
+ return (
+
+
+
+ {title}
+
+
+ {description}
+
+
+
+ {new Date(createdAt).toLocaleString()}
+
+
+
+
+ e.stopPropagation()} // spriječi bubbling na card
+ >
+
+
+
+ onDelete(ticket)} size='large'>
+
+
+ onResolve(ticket)}
+ size='large'
+ disabled={status === 'Resolved'}
+ >
+
+
+
+
+ );
+}
diff --git a/src/components/TicketListSection.jsx b/src/components/TicketListSection.jsx
new file mode 100644
index 0000000..b1f6bec
--- /dev/null
+++ b/src/components/TicketListSection.jsx
@@ -0,0 +1,155 @@
+import React, { useState } from 'react';
+import { Box, TextField, Typography } from '@mui/material';
+import TicketCard from './TicketCard';
+import SearchIcon from '@mui/icons-material/Search';
+import InputAdornment from '@mui/material/InputAdornment';
+import DeleteConfirmModal from '@components/DeleteConfirmModal';
+import { apiUpdateTicketStatusAsync } from '../api/api.js'; // prilagodi path
+import { apiDeleteTicketAsync } from '../api/api.js'; // prilagodi path
+import { useTranslation } from 'react-i18next';
+
+export default function TicketListSection({
+ tickets,
+ selectedTicketId,
+ setSelectedTicketId,
+ unlockedTickets,
+ onUnlockChat,
+ refreshTickets,
+ setTickets, // Dodaj ovaj prop iz ChatPage.jsx
+}) {
+ const [search, setSearch] = useState('');
+ const [deleteModalOpen, setDeleteModalOpen] = useState(false);
+ const [ticketToDelete, setTicketToDelete] = useState(null);
+ const { t } = useTranslation();
+ const handleOpenChat = async (ticketId) => {
+ const ticket = tickets.find((t) => t.id === ticketId);
+ if (ticket.status === 'Requested') {
+ await apiUpdateTicketStatusAsync(ticketId, 'Open');
+ if (refreshTickets) await refreshTickets();
+ }
+ setSelectedTicketId(ticketId); // Samo selektuj ticket
+ };
+
+ const handleDelete = (ticket) => {
+ setTicketToDelete(ticket);
+ setDeleteModalOpen(true);
+ };
+
+ const handleConfirmDelete = async () => {
+ if (!ticketToDelete) return;
+ const { status } = await apiDeleteTicketAsync(ticketToDelete.id);
+ if (status === 204) {
+ setTickets((prev) => prev.filter((t) => t.id !== ticketToDelete.id));
+ if (selectedTicketId === ticketToDelete.id) setSelectedTicketId(null);
+ } else {
+ alert('Failed to delete ticket.');
+ }
+ setDeleteModalOpen(false);
+ setTicketToDelete(null);
+ };
+
+ const handleResolve = async (ticket) => {
+ if (ticket.status !== 'Resolved') {
+ await apiUpdateTicketStatusAsync(ticket.id, 'Resolved');
+ if (refreshTickets) refreshTickets();
+ }
+ };
+
+ const filteredTickets = tickets.filter(
+ (t) =>
+ t.title.toLowerCase().includes(search.toLowerCase()) ||
+ t.description.toLowerCase().includes(search.toLowerCase())
+ );
+
+ return (
+ <>
+
+
+ {t('common.tickets')}
+
+ setSearch(e.target.value)}
+ sx={{
+ mb: 2,
+ ml: 1,
+ width: 335,
+ borderRadius: 3,
+ background: '#fff',
+ boxShadow: '0 2px 8px 0 rgba(60,72,88,0.08)',
+ '& .MuiOutlinedInput-root': {
+ borderRadius: 3,
+ background: '#fff',
+ },
+ '& .MuiOutlinedInput-notchedOutline': {
+ borderColor: '#e0e0e0',
+ },
+ '&:hover .MuiOutlinedInput-notchedOutline': {
+ borderColor: '#bdbdbd',
+ },
+ '& .MuiInputAdornment-root': {
+ color: '#bdbdbd',
+ },
+ }}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ />
+
+
+ {filteredTickets.length === 0 ? (
+
+ {t('common.noTicketsFound')}
+
+ ) : (
+ filteredTickets.map((ticket) => (
+ setSelectedTicketId(ticket.id)}
+ onOpenChat={() => handleOpenChat(ticket.id)}
+ onDelete={handleDelete}
+ onResolve={handleResolve}
+ />
+ ))
+ )}
+
+
+ setDeleteModalOpen(false)}
+ onConfirm={handleConfirmDelete}
+ ticketTitle={ticketToDelete?.title}
+ />
+ >
+ );
+}
diff --git a/src/components/UserAvatar.jsx b/src/components/UserAvatar.jsx
new file mode 100644
index 0000000..76513c0
--- /dev/null
+++ b/src/components/UserAvatar.jsx
@@ -0,0 +1,11 @@
+import React from "react";
+import { Avatar } from "@mui/material";
+import AccountCircleIcon from "@mui/icons-material/AccountCircle";
+
+const UserAvatar = () => (
+
+
+
+);
+
+export default UserAvatar;
\ No newline at end of file
diff --git a/src/components/UserDetailsModal.jsx b/src/components/UserDetailsModal.jsx
new file mode 100644
index 0000000..f242a02
--- /dev/null
+++ b/src/components/UserDetailsModal.jsx
@@ -0,0 +1,126 @@
+import React, { useState, useEffect } from "react";
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ IconButton,
+ Typography,
+ Button,
+ Box,
+} from "@mui/material";
+import CloseIcon from "@mui/icons-material/Close";
+
+import UserAvatar from "../components/UserAvatar.jsx";
+import UserName from "../components/UserName.jsx";
+import UserEmail from "../components/UserEmail.jsx";
+import UserPhone from "../components/UserPhone.jsx";
+import UserRoles from "../components/UserRoles.jsx";
+import UserEditForm from "../components/UserEditForm.jsx";
+
+const UserDetailsModal = ({ open, onClose, user, readOnly = false }) => {
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [isEditing, setIsEditing] = useState(false);
+
+ useEffect(() => {
+ if (user) {
+ setSelectedUser(user);
+ setIsEditing(false);
+ }
+ }, [user]);
+
+ if (!selectedUser) return null;
+
+ const handleEditToggle = () => {
+ setIsEditing((prev) => !prev);
+ };
+
+ const handleUserSave = (updatedUser) => {
+ setSelectedUser(updatedUser);
+ setIsEditing(false);
+ };
+
+ return (
+
+ );
+};
+
+export default UserDetailsModal;
diff --git a/src/components/UserDistribution.jsx b/src/components/UserDistribution.jsx
new file mode 100644
index 0000000..ad63309
--- /dev/null
+++ b/src/components/UserDistribution.jsx
@@ -0,0 +1,208 @@
+import React, { useEffect, useState, useRef } from 'react';
+import { Card, CardContent, Typography, Box } from '@mui/material';
+import { PieChart, Pie, Cell, ResponsiveContainer } from 'recharts';
+import { apiGetAllAdsAsync } from '../api/api.js';
+import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
+import { useTranslation } from 'react-i18next';
+
+const gaugeColor = '#0F766E';
+const bgColor = '#E5E7EB';
+const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
+const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub';
+const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`;
+
+const UserDistribution = () => {
+ const { t } = useTranslation();
+ const [conversionRate, setConversionRate] = useState(0);
+ const [totalConversions, setTotalConversions] = useState(0);
+ const [totalClicks, setTotalClicks] = useState(0);
+ const [ads, setAds] = useState([]);
+ const connectionRef = useRef(null);
+
+ useEffect(() => {
+ const fetchInitialData = async () => {
+ try {
+ const adsResponse = await apiGetAllAdsAsync();
+ const adsData = adsResponse.data;
+ setAds(adsData);
+
+ // Calculate initial conversions and clicks
+ const totalConversions = adsData.reduce(
+ (sum, ad) => sum + (ad.conversions || 0),
+ 0
+ );
+ const totalClicks = adsData.reduce(
+ (sum, ad) => sum + (ad.clicks || 0),
+ 0
+ );
+
+ setTotalConversions(totalConversions);
+ setTotalClicks(totalClicks);
+ setConversionRate(
+ totalClicks > 0 ? (totalConversions / totalClicks) * 100 : 0
+ );
+ } catch (error) {
+ console.error('Error fetching initial ads data:', error);
+ }
+ };
+
+ fetchInitialData();
+
+ // Initialize SignalR connection
+ const jwtToken = localStorage.getItem('token');
+ if (!jwtToken) {
+ console.warn('No JWT token found. SignalR connection not started.');
+ return;
+ }
+
+ const newConnection = new HubConnectionBuilder()
+ .withUrl(HUB_URL, {
+ accessTokenFactory: () => jwtToken,
+ })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(LogLevel.Information)
+ .build();
+
+ connectionRef.current = newConnection;
+
+ const startConnection = async () => {
+ try {
+ await newConnection.start();
+ console.log('SignalR Connected to AdvertisementHub!');
+ } catch (err) {
+ console.error('SignalR Connection Error:', err);
+ }
+ };
+
+ startConnection();
+
+ // Register event handlers
+ newConnection.on('ReceiveAdUpdate', (updatedAd) => {
+ console.log('Received Ad Update:', updatedAd);
+ setAds((prevAds) => {
+ const updatedAds = prevAds.map((ad) =>
+ ad.id === updatedAd.id ? updatedAd : ad
+ );
+
+ // If the ad is new, add it
+ if (!updatedAds.some((ad) => ad.id === updatedAd.id)) {
+ updatedAds.push(updatedAd);
+ }
+
+ // Recalculate conversions and clicks
+ const totalConversions = updatedAds.reduce(
+ (sum, ad) => sum + (ad.conversions || 0),
+ 0
+ );
+ const totalClicks = updatedAds.reduce(
+ (sum, ad) => sum + (ad.clicks || 0),
+ 0
+ );
+
+ setTotalConversions(totalConversions);
+ setTotalClicks(totalClicks);
+ setConversionRate(
+ totalClicks > 0 ? (totalConversions / totalClicks) * 100 : 0
+ );
+
+ return updatedAds;
+ });
+ });
+
+ newConnection.on('ReceiveClickTimestamp', () => {
+ console.log('Received Click Timestamp');
+ setTotalClicks((prev) => {
+ const newTotalClicks = prev + 1;
+ setConversionRate(
+ newTotalClicks > 0 ? (totalConversions / newTotalClicks) * 100 : 0
+ );
+ return newTotalClicks;
+ });
+ });
+
+ newConnection.on('ReceiveConversionTimestamp', () => {
+ console.log('Received Conversion Timestamp');
+ setTotalConversions((prev) => {
+ const newTotalConversions = prev + 1;
+ setConversionRate(
+ totalClicks > 0 ? (newTotalConversions / totalClicks) * 100 : 0
+ );
+ return newTotalConversions;
+ });
+ });
+
+ // Cleanup on unmount
+ return () => {
+ if (
+ connectionRef.current &&
+ connectionRef.current.state === 'Connected'
+ ) {
+ console.log('Stopping SignalR connection on component unmount.');
+ connectionRef.current
+ .stop()
+ .catch((err) =>
+ console.error('Error stopping SignalR connection:', err)
+ );
+ }
+ };
+ }, [totalClicks, totalConversions]);
+
+ const gaugeData = [
+ { name: 'Conversion Rate', value: conversionRate, color: gaugeColor },
+ { name: 'Remaining', value: 100 - conversionRate, color: bgColor },
+ ];
+
+ return (
+
+
+
+ {t('analytics.conversionRate')}
+
+
+ {totalConversions} {t('analytics.conversions')} / {totalClicks} {t('analytics.clicks')}
+
+
+
+
+
+
+ {gaugeData.map((entry, idx) => (
+ |
+ ))}
+
+
+
+
+
+ {totalClicks > 0 ? conversionRate.toFixed(1) : 0}%
+
+
+
+
+ );
+};
+
+export default UserDistribution;
diff --git a/src/components/UserEditForm.jsx b/src/components/UserEditForm.jsx
new file mode 100644
index 0000000..cae8169
--- /dev/null
+++ b/src/components/UserEditForm.jsx
@@ -0,0 +1,84 @@
+import React, { useState } from "react";
+import { TextField, Button, Box, Typography, MenuItem, Select, FormControl, InputLabel } from "@mui/material";
+import { updateUser } from '../data/usersDetails.js'; // Importuj funkciju za ažuriranje korisnika
+
+const UserEditForm = ({ user, onSave }) => {
+ const [name, setName] = useState(user.name);
+ const [email, setEmail] = useState(user.email);
+ const [role, setRole] = useState(user.role);
+ const [phoneNumber, setPhoneNumber] = useState(user.phoneNumber || ''); // Dodano polje za broj telefona
+
+ // Funkcija za obradu promene u imenu
+ const handleNameChange = (e) => setName(e.target.value);
+
+ // Funkcija za obradu promene u emailu
+ const handleEmailChange = (e) => setEmail(e.target.value);
+
+ // Funkcija za obradu promene u roli
+ const handleRoleChange = (e) => setRole(e.target.value);
+
+ // Funkcija za obradu promene u broju telefona
+ const handlePhoneNumberChange = (e) => setPhoneNumber(e.target.value);
+
+ // Funkcija za sačuvanje promena
+ const handleSave = () => {
+ const updatedUser = { name, email, role, phoneNumber };
+ updateUser(user.id, updatedUser); // Ažuriraj korisnika u bazi podataka
+ onSave(updatedUser); // Osvježi roditeljsku komponentu
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ Role
+
+
+
+
+
+ );
+};
+
+export default UserEditForm;
\ No newline at end of file
diff --git a/src/components/UserEmail.jsx b/src/components/UserEmail.jsx
new file mode 100644
index 0000000..92fde5c
--- /dev/null
+++ b/src/components/UserEmail.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import { Typography } from "@mui/material";
+
+const UserEmail = ({ email }) => (
+
+ {email}
+
+);
+
+export default UserEmail;
\ No newline at end of file
diff --git a/src/components/UserInfoSidebar.jsx b/src/components/UserInfoSidebar.jsx
new file mode 100644
index 0000000..6a6c72c
--- /dev/null
+++ b/src/components/UserInfoSidebar.jsx
@@ -0,0 +1,45 @@
+// @components/UserInfoSidebar.jsx
+import {
+ Box,
+ Paper,
+ Typography,
+ Stack,
+ Divider,
+ Avatar,
+ List,
+ ListItem,
+ ListItemIcon,
+ ListItemText,
+} from '@mui/material';
+import StoreIcon from '@mui/icons-material/Store';
+import CalendarMonthIcon from '@mui/icons-material/CalendarMonth';
+
+export default function UserInfoSidebar({ username, storeName }) {
+ return (
+
+
+
+ {username || 'User'} {' '}
+ {storeName && (
+
+
+
+ {storeName}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/UserList.jsx b/src/components/UserList.jsx
new file mode 100644
index 0000000..4f50278
--- /dev/null
+++ b/src/components/UserList.jsx
@@ -0,0 +1,344 @@
+import React, { useState } from 'react';
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableContainer,
+ TableHead,
+ TableRow,
+ Paper,
+ Avatar,
+ TableSortLabel,
+ Typography,
+ Chip,
+ Box,
+ IconButton,
+ TextField,
+ Select,
+ MenuItem,
+ Tooltip,
+} from '@mui/material';
+import DeleteUserButton from './DeleteUserButton';
+import { FiEdit2 } from 'react-icons/fi';
+import { FaUser, FaUserSlash } from 'react-icons/fa';
+import { MdDone } from 'react-icons/md';
+import { useTranslation } from 'react-i18next';
+
+const getStatus = (user) => {
+ if (user.isApproved === true) return 'Approved';
+ if (user.isApproved === false) return 'Rejected';
+ return 'Pending';
+};
+
+const StatusChip = ({ status }) => {
+ let color = '#800000';
+ let bg = '#e6f7ff';
+ if (status === 'Approved') bg = '#e6f7ed';
+ if (status === 'Rejected') bg = '#ffe6e6';
+
+ return (
+
+ );
+};
+
+const ActiveChip = ({ value }) => {
+ const isActive = value === true;
+ return (
+
+ );
+};
+
+export default function UserList({
+ users,
+ onDelete,
+ onEdit,
+ onView,
+ currentPage,
+ usersPerPage,
+}) {
+ const [orderBy, setOrderBy] = useState('name');
+ const [order, setOrder] = useState('asc');
+ const [editingUserId, setEditingUserId] = useState(null);
+ const [editedUser, setEditedUser] = useState({});
+ const { t } = useTranslation();
+ const handleSort = (field) => {
+ const isAsc = orderBy === field && order === 'asc';
+ setOrder(isAsc ? 'desc' : 'asc');
+ setOrderBy(field);
+ };
+
+ const handleEditClick = (user) => {
+ setEditingUserId(user.id);
+ setEditedUser({ ...user });
+ };
+
+ const handleSaveEdit = () => {
+ onEdit(editedUser);
+ setEditingUserId(null);
+ };
+
+ const handleFieldChange = (e) => {
+ const { name, value } = e.target;
+ setEditedUser((prev) => ({ ...prev, [name]: value }));
+ };
+
+ const sortUsers = [...users].sort((a, b) => {
+ const valA = orderBy === 'status' ? getStatus(a) : a[orderBy];
+ const valB = orderBy === 'status' ? getStatus(b) : b[orderBy];
+
+ if (!valA) return 1;
+ if (!valB) return -1;
+
+ if (typeof valA === 'string') {
+ return order === 'asc'
+ ? valA.localeCompare(valB)
+ : valB.localeCompare(valA);
+ }
+
+ return order === 'asc' ? valA - valB : valB - valA;
+ });
+
+ return (
+
+
+
+
+ #
+ {t('common.picture')}
+
+ handleSort('userName')}
+ >
+ {t('common.username')}
+
+
+
+ handleSort('email')}
+ >
+ {t('common.email')}
+
+
+
+ handleSort('role')}
+ >
+ {t('common.role')}
+
+
+
+ handleSort('isActive')}
+ >
+ {t('common.active')}
+
+
+ {/*
+ handleSort("lastActive")}
+ >
+ Last Active
+
+ */}
+ {/*
+ handleSort('status')}
+ >
+ Status
+
+ */}
+ {t('common.actions')}
+
+
+
+
+ {sortUsers.map((user, index) => {
+ const isEditing = editingUserId === user.id;
+ return (
+
+
+ {(currentPage - 1) * usersPerPage + index + 1}
+
+
+
+
+
+
+ {isEditing ? (
+
+ ) : (
+ user.userName
+ )}
+
+
+
+ {isEditing ? (
+
+ ) : (
+ user.email
+ )}
+
+
+
+ {isEditing ? (
+
+ ) : (
+ user.roles[0]
+ )}
+
+
+
+ {isEditing ? (
+
+ ) : (
+
+ )}
+
+
+ {/* {user.lastActive}
+
+
+ */}
+
+
+
+
+ {
+ e.stopPropagation();
+ onEdit({
+ ...user,
+ isActive: !user.isActive,
+ toggleAvailabilityOnly: true, // da backend zna
+ });
+ }}
+ >
+ {user.isActive ? (
+
+ ) : (
+
+ )}
+
+
+
+ {
+ e.stopPropagation();
+ if (isEditing) {
+ handleSaveEdit();
+ } else {
+ handleEditClick(user);
+ }
+ }}
+ >
+ {isEditing ? (
+
+ ) : (
+
+ )}
+
+
+
+
+ {
+ e.stopPropagation();
+ onDelete(user.id);
+ }}
+ />
+
+
+
+
+ );
+ })}
+
+
+
+ );
+}
diff --git a/src/components/UserManagementPagination.jsx b/src/components/UserManagementPagination.jsx
new file mode 100644
index 0000000..2f68827
--- /dev/null
+++ b/src/components/UserManagementPagination.jsx
@@ -0,0 +1,102 @@
+import React from "react";
+import { Box, Typography, IconButton, Button } from "@mui/material";
+import NavigateBeforeIcon from "@mui/icons-material/NavigateBefore";
+import NavigateNextIcon from "@mui/icons-material/NavigateNext";
+import { useTranslation } from 'react-i18next';
+
+const UserManagementPagination = ({
+ currentPage,
+ totalPages,
+ onPageChange,
+}) => {
+ const getPages = () => {
+ const pages = [];
+ const maxVisible = 8;
+ const startPage = Math.max(1, currentPage - 2);
+ const endPage = Math.min(totalPages, startPage + maxVisible - 1);
+
+ for (let i = startPage; i <= endPage; i++) {
+ pages.push(i);
+ }
+
+ return pages;
+ };
+
+ const { t } = useTranslation();
+
+ return (
+
+
+ {t('common.displayingPage')}
+
+
+
+
+
+ onPageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ >
+
+
+
+ {getPages().map((page) => (
+
+ ))}
+
+ {currentPage + 2 < totalPages && (
+
+ ...
+
+ )}
+
+ onPageChange(currentPage + 1)}
+ disabled={currentPage === totalPages}
+ >
+
+
+
+
+
+
+ );
+};
+
+export default UserManagementPagination;
+
diff --git a/src/components/UserName.jsx b/src/components/UserName.jsx
new file mode 100644
index 0000000..0668a47
--- /dev/null
+++ b/src/components/UserName.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import { Typography } from "@mui/material";
+
+const UserName = ({ userName }) => (
+
+ {userName}
+
+);
+
+export default UserName;
\ No newline at end of file
diff --git a/src/components/UserPhone.jsx b/src/components/UserPhone.jsx
new file mode 100644
index 0000000..85e8f3e
--- /dev/null
+++ b/src/components/UserPhone.jsx
@@ -0,0 +1,10 @@
+import React from "react";
+import { Typography } from "@mui/material";
+
+const UserPhone = ({ phoneNumber }) => (
+
+ Telefon: {phoneNumber || "N/A"}
+
+);
+
+export default UserPhone;
\ No newline at end of file
diff --git a/src/components/UserRoles.jsx b/src/components/UserRoles.jsx
new file mode 100644
index 0000000..368c08f
--- /dev/null
+++ b/src/components/UserRoles.jsx
@@ -0,0 +1,10 @@
+import React from 'react';
+import { Typography } from '@mui/material';
+
+const UserRoles = ({ roles }) => {
+ return (
+ {roles}
+ );
+};
+
+export default UserRoles;
\ No newline at end of file
diff --git a/src/components/ValidatedTextField.jsx b/src/components/ValidatedTextField.jsx
new file mode 100644
index 0000000..5b8f374
--- /dev/null
+++ b/src/components/ValidatedTextField.jsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import CustomTextField from './CustomTextField';
+
+const ValidatedTextField = ({ error, helperText, sx, ...props }) => {
+ return (
+
+ );
+};
+
+export default ValidatedTextField;
diff --git a/src/context/PendingUsersContext.jsx b/src/context/PendingUsersContext.jsx
new file mode 100644
index 0000000..37fc940
--- /dev/null
+++ b/src/context/PendingUsersContext.jsx
@@ -0,0 +1,40 @@
+// src/context/PendingUsersContext.js
+import React, { createContext, useContext, useState, useEffect } from "react";
+import { apiFetchPendingUsersAsync } from "../api/api.js";
+
+export const PendingUsersContext = createContext();
+
+export const usePendingUsers = () => useContext(PendingUsersContext);
+
+export const PendingUsersProvider = ({ children }) => {
+ const [pendingUsers, setPendingUsers] = useState([]);
+
+ useEffect(() => {
+ async function fetchData() {
+ try {
+ const users = await apiFetchPendingUsersAsync();
+ setPendingUsers(users);
+ console.log("Fetched users:", users);
+ } catch (error) {
+ console.error("Neuspješno dohvaćanje korisnika:", error);
+ }
+ }
+ fetchData();
+ }, []);
+
+ const approveUser = (id) => {
+ setPendingUsers((prev) => prev.filter((u) => u.id !== id));
+ };
+
+ const deleteUser = (id) => {
+ setPendingUsers((prev) => prev.filter((u) => u.id !== id));
+ };
+
+ return (
+
+ {children}
+
+ );
+};
diff --git a/src/data/.gitkeep b/src/data/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/data/ads.js b/src/data/ads.js
new file mode 100644
index 0000000..a8986ec
--- /dev/null
+++ b/src/data/ads.js
@@ -0,0 +1,36 @@
+const Ads = [
+ {
+ sellerId: '1',
+ startTime: '2025-05-01T08:00:00Z',
+ endTime: '2025-05-10T23:59:59Z',
+ AdData: [
+ {
+ Description: 'Super ponuda - 50% popusta na sve patike!',
+ Image: 'https://example.com/images/ad1.jpg',
+ ProductLink: '1',
+ StoreLink: '1'
+ },
+ {
+ Description: 'Kupite jedan, drugi gratis! Akcija traje do isteka zaliha.',
+ Image: 'https://example.com/images/ad2.jpg',
+ ProductLink: '2',
+ StoreLink: '2'
+ }
+ ]
+ },
+ {
+ sellerId: 'seller456',
+ startTime: '2025-05-05T00:00:00Z',
+ endTime: '2025-05-15T23:59:59Z',
+ AdData: [
+ {
+ Description: 'Nova kolekcija proljeće/ljeto 2025. Pogledajte sada!',
+ Image: 'https://example.com/images/ad3.jpg',
+ ProductLink: '2',
+ StoreLink: '2'
+ }
+ ]
+ }
+ ];
+
+ export default Ads;
\ No newline at end of file
diff --git a/src/data/categories.js b/src/data/categories.js
new file mode 100644
index 0000000..28744a3
--- /dev/null
+++ b/src/data/categories.js
@@ -0,0 +1,33 @@
+let categories = [
+ {
+ id: 1,
+ name: "kategorija1",
+ type: "product"
+ },
+ {
+ id: 2,
+ name: "kategorija2",
+ type: "store"
+ },
+ {
+ id: 3,
+ name: "kategorija3",
+ type: "product"
+ },
+ {
+ id: 4,
+ name: "kategorija4",
+ type: "store"
+ },
+ {
+ id: 5,
+ name: "kategorija5",
+ type: "product"
+ },
+ {
+ id: 6,
+ name: "kategorija6",
+ type: "store"
+ },
+]
+export default categories;
\ No newline at end of file
diff --git a/src/data/mockAds.js b/src/data/mockAds.js
new file mode 100644
index 0000000..f0736de
--- /dev/null
+++ b/src/data/mockAds.js
@@ -0,0 +1,21 @@
+/*export const mockAds = [
+ {
+ id: "AD-001245",
+ sellerId: "SELLER-001",
+ Views: 541200,
+ Clicks: 46250,
+ startTime: "2021-01-25T00:00:00Z",
+ endTime: "2021-12-31T00:00:00Z",
+ isActive: true,
+ AdData: [
+ {
+ id: 1,
+ Description: "50% OFF Floor Lamp Get it Now!",
+ Image: "https://via.placeholder.com/150",
+ ProductLink: "https://example.com/product/floor-lamp",
+ StoreLink: "https://example.com/store",
+ },
+ ],
+ },
+];*/
+
\ No newline at end of file
diff --git a/src/data/pendingUsers.js b/src/data/pendingUsers.js
new file mode 100644
index 0000000..ea34ff2
--- /dev/null
+++ b/src/data/pendingUsers.js
@@ -0,0 +1,5 @@
+import users from "./users.js"
+
+let pendingUsers = users.filter(u=> !u.isApproved);
+
+export default pendingUsers;
\ No newline at end of file
diff --git a/src/data/products.js b/src/data/products.js
new file mode 100644
index 0000000..204854a
--- /dev/null
+++ b/src/data/products.js
@@ -0,0 +1,1239 @@
+let products = [
+ {
+ id: 1,
+ name: 'Proizvod 1 - Nova Market',
+ price: 98.15,
+ weight: 3.16,
+ weightunit: 'lbs',
+ volume: 1.69,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 1,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 2,
+ name: 'Proizvod 2 - Nova Market',
+ price: 15.97,
+ weight: 1.6,
+ weightunit: 'kg',
+ volume: 1.46,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 1,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 3,
+ name: 'Proizvod 3 - Nova Market',
+ price: 81.78,
+ weight: 1.01,
+ weightunit: 'kg',
+ volume: 1.53,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 1,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 4,
+ name: 'Proizvod 4 - Nova Market',
+ price: 33.99,
+ weight: 3.67,
+ weightunit: 'lbs',
+ volume: 1.41,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 1,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 5,
+ name: 'Proizvod 5 - Nova Market',
+ price: 14.45,
+ weight: 4.9,
+ weightunit: 'lbs',
+ volume: 2.68,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 1,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 6,
+ name: 'Proizvod 1 - Tech World',
+ price: 41.53,
+ weight: 4.82,
+ weightunit: 'lbs',
+ volume: 0.83,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 2,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 7,
+ name: 'Proizvod 2 - Tech World',
+ price: 20.78,
+ weight: 4.95,
+ weightunit: 'kg',
+ volume: 2.61,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 2,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 8,
+ name: 'Proizvod 3 - Tech World',
+ price: 60.7,
+ weight: 0.21,
+ weightunit: 'g',
+ volume: 1.86,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 2,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 9,
+ name: 'Proizvod 4 - Tech World',
+ price: 61.91,
+ weight: 2.65,
+ weightunit: 'kg',
+ volume: 1.54,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 2,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 10,
+ name: 'Proizvod 5 - Tech World',
+ price: 76.28,
+ weight: 0.5,
+ weightunit: 'kg',
+ volume: 2.24,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 2,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 11,
+ name: 'Proizvod 1 - BioShop',
+ price: 90.87,
+ weight: 3.65,
+ weightunit: 'lbs',
+ volume: 2.94,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 3,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 12,
+ name: 'Proizvod 2 - BioShop',
+ price: 43.25,
+ weight: 3.75,
+ weightunit: 'lbs',
+ volume: 0.14,
+ volumeunit: 'L',
+ productcategoryid: 6,
+ storeId: 3,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 13,
+ name: 'Proizvod 3 - BioShop',
+ price: 22.33,
+ weight: 0.33,
+ weightunit: 'lbs',
+ volume: 2.76,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 3,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 14,
+ name: 'Proizvod 4 - BioShop',
+ price: 84.77,
+ weight: 2.97,
+ weightunit: 'g',
+ volume: 0.28,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 3,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 15,
+ name: 'Proizvod 5 - BioShop',
+ price: 23.74,
+ weight: 4.3,
+ weightunit: 'kg',
+ volume: 2.38,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 3,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 16,
+ name: 'Proizvod 1 - Fashion Spot',
+ price: 34.13,
+ weight: 4.67,
+ weightunit: 'g',
+ volume: 0.77,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 4,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 17,
+ name: 'Proizvod 2 - Fashion Spot',
+ price: 46.35,
+ weight: 2.24,
+ weightunit: 'g',
+ volume: 1.35,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 4,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 18,
+ name: 'Proizvod 3 - Fashion Spot',
+ price: 70.68,
+ weight: 2.06,
+ weightunit: 'kg',
+ volume: 2.42,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 4,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 19,
+ name: 'Proizvod 4 - Fashion Spot',
+ price: 67.49,
+ weight: 3.99,
+ weightunit: 'lbs',
+ volume: 1.94,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 4,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 20,
+ name: 'Proizvod 5 - Fashion Spot',
+ price: 86.13,
+ weight: 1.58,
+ weightunit: 'kg',
+ volume: 2.44,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 4,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 21,
+ name: 'Proizvod 1 - Office Plus',
+ price: 31.87,
+ weight: 1.66,
+ weightunit: 'kg',
+ volume: 0.26,
+ volumeunit: 'L',
+ productcategoryid: 4,
+ storeId: 5,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 22,
+ name: 'Proizvod 2 - Office Plus',
+ price: 23.21,
+ weight: 3.64,
+ weightunit: 'g',
+ volume: 1.14,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 5,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 23,
+ name: 'Proizvod 3 - Office Plus',
+ price: 55.47,
+ weight: 2.71,
+ weightunit: 'lbs',
+ volume: 0.83,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 5,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 24,
+ name: 'Proizvod 4 - Office Plus',
+ price: 32.69,
+ weight: 1.19,
+ weightunit: 'g',
+ volume: 2.29,
+ volumeunit: 'L',
+ productcategoryid: 4,
+ storeId: 5,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 25,
+ name: 'Proizvod 5 - Office Plus',
+ price: 85.67,
+ weight: 2.23,
+ weightunit: 'g',
+ volume: 2.24,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 5,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 26,
+ name: 'Proizvod 1 - Auto Centar',
+ price: 28.39,
+ weight: 2.23,
+ weightunit: 'lbs',
+ volume: 1.75,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 6,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 27,
+ name: 'Proizvod 2 - Auto Centar',
+ price: 69.33,
+ weight: 2.8,
+ weightunit: 'kg',
+ volume: 1.33,
+ volumeunit: 'L',
+ productcategoryid: 6,
+ storeId: 6,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 28,
+ name: 'Proizvod 3 - Auto Centar',
+ price: 21.79,
+ weight: 0.78,
+ weightunit: 'g',
+ volume: 2.87,
+ volumeunit: 'L',
+ productcategoryid: 6,
+ storeId: 6,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 29,
+ name: 'Proizvod 4 - Auto Centar',
+ price: 58.72,
+ weight: 2.24,
+ weightunit: 'lbs',
+ volume: 0.94,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 6,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 30,
+ name: 'Proizvod 5 - Auto Centar',
+ price: 11.48,
+ weight: 4.07,
+ weightunit: 'lbs',
+ volume: 2.18,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 6,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 31,
+ name: 'Proizvod 1 - Pet Planet',
+ price: 36.24,
+ weight: 0.43,
+ weightunit: 'lbs',
+ volume: 1.46,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 7,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 32,
+ name: 'Proizvod 2 - Pet Planet',
+ price: 10.93,
+ weight: 1.67,
+ weightunit: 'kg',
+ volume: 2.84,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 7,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 33,
+ name: 'Proizvod 3 - Pet Planet',
+ price: 30.42,
+ weight: 2.76,
+ weightunit: 'lbs',
+ volume: 1.0,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 7,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 34,
+ name: 'Proizvod 4 - Pet Planet',
+ price: 96.84,
+ weight: 4.39,
+ weightunit: 'kg',
+ volume: 0.33,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 7,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 35,
+ name: 'Proizvod 5 - Pet Planet',
+ price: 19.83,
+ weight: 4.5,
+ weightunit: 'kg',
+ volume: 2.16,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 7,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 36,
+ name: 'Proizvod 1 - Green Garden',
+ price: 26.15,
+ weight: 1.44,
+ weightunit: 'g',
+ volume: 1.98,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 8,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 37,
+ name: 'Proizvod 2 - Green Garden',
+ price: 57.42,
+ weight: 1.61,
+ weightunit: 'kg',
+ volume: 1.91,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 8,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 38,
+ name: 'Proizvod 3 - Green Garden',
+ price: 8.21,
+ weight: 4.83,
+ weightunit: 'kg',
+ volume: 0.22,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 8,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 39,
+ name: 'Proizvod 4 - Green Garden',
+ price: 50.65,
+ weight: 4.81,
+ weightunit: 'kg',
+ volume: 0.69,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 8,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 40,
+ name: 'Proizvod 5 - Green Garden',
+ price: 75.39,
+ weight: 1.66,
+ weightunit: 'lbs',
+ volume: 0.33,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 8,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 41,
+ name: 'Proizvod 1 - Kids Toys',
+ price: 72.01,
+ weight: 2.09,
+ weightunit: 'lbs',
+ volume: 2.5,
+ volumeunit: 'L',
+ productcategoryid: 6,
+ storeId: 9,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 42,
+ name: 'Proizvod 2 - Kids Toys',
+ price: 93.6,
+ weight: 3.67,
+ weightunit: 'kg',
+ volume: 0.29,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 9,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 43,
+ name: 'Proizvod 3 - Kids Toys',
+ price: 97.21,
+ weight: 0.55,
+ weightunit: 'kg',
+ volume: 0.41,
+ volumeunit: 'L',
+ productcategoryid: 6,
+ storeId: 9,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 44,
+ name: 'Proizvod 4 - Kids Toys',
+ price: 87.46,
+ weight: 1.72,
+ weightunit: 'g',
+ volume: 2.34,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 9,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 45,
+ name: 'Proizvod 5 - Kids Toys',
+ price: 63.83,
+ weight: 2.05,
+ weightunit: 'kg',
+ volume: 2.15,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 9,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 46,
+ name: 'Proizvod 1 - Mega Market',
+ price: 72.02,
+ weight: 0.74,
+ weightunit: 'kg',
+ volume: 1.0,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 10,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 47,
+ name: 'Proizvod 2 - Mega Market',
+ price: 67.61,
+ weight: 0.22,
+ weightunit: 'g',
+ volume: 0.35,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 10,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 48,
+ name: 'Proizvod 3 - Mega Market',
+ price: 47.57,
+ weight: 1.04,
+ weightunit: 'lbs',
+ volume: 1.99,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 10,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 49,
+ name: 'Proizvod 4 - Mega Market',
+ price: 31.82,
+ weight: 1.61,
+ weightunit: 'g',
+ volume: 1.72,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 10,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 50,
+ name: 'Proizvod 5 - Mega Market',
+ price: 15.47,
+ weight: 2.2,
+ weightunit: 'g',
+ volume: 2.19,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 10,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 51,
+ name: 'Proizvod 1 - Green Garden',
+ price: 43.8,
+ weight: 0.65,
+ weightunit: 'g',
+ volume: 1.36,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 11,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 52,
+ name: 'Proizvod 2 - Green Garden',
+ price: 64.01,
+ weight: 4.12,
+ weightunit: 'kg',
+ volume: 2.98,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 11,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 53,
+ name: 'Proizvod 3 - Green Garden',
+ price: 56.9,
+ weight: 1.63,
+ weightunit: 'g',
+ volume: 1.65,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 11,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 54,
+ name: 'Proizvod 4 - Green Garden',
+ price: 81.11,
+ weight: 2.52,
+ weightunit: 'lbs',
+ volume: 0.48,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 11,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 55,
+ name: 'Proizvod 5 - Green Garden',
+ price: 96.97,
+ weight: 4.15,
+ weightunit: 'kg',
+ volume: 2.64,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 11,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 56,
+ name: 'Proizvod 1 - Kids Toys',
+ price: 24.87,
+ weight: 2.4,
+ weightunit: 'g',
+ volume: 0.9,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 12,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 57,
+ name: 'Proizvod 2 - Kids Toys',
+ price: 23.0,
+ weight: 0.34,
+ weightunit: 'kg',
+ volume: 1.8,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 12,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 58,
+ name: 'Proizvod 3 - Kids Toys',
+ price: 64.82,
+ weight: 4.21,
+ weightunit: 'kg',
+ volume: 2.17,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 12,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 59,
+ name: 'Proizvod 4 - Kids Toys',
+ price: 81.12,
+ weight: 4.26,
+ weightunit: 'kg',
+ volume: 1.79,
+ volumeunit: 'L',
+ productcategoryid: 6,
+ storeId: 12,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 60,
+ name: 'Proizvod 5 - Kids Toys',
+ price: 15.55,
+ weight: 1.11,
+ weightunit: 'lbs',
+ volume: 1.31,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 12,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 61,
+ name: 'Proizvod 1 - Mega Market',
+ price: 43.55,
+ weight: 1.39,
+ weightunit: 'g',
+ volume: 2.83,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 13,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 62,
+ name: 'Proizvod 2 - Mega Market',
+ price: 95.59,
+ weight: 1.12,
+ weightunit: 'kg',
+ volume: 0.47,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 13,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 63,
+ name: 'Proizvod 3 - Mega Market',
+ price: 72.76,
+ weight: 2.67,
+ weightunit: 'g',
+ volume: 2.31,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 13,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 64,
+ name: 'Proizvod 4 - Mega Market',
+ price: 55.62,
+ weight: 4.23,
+ weightunit: 'lbs',
+ volume: 2.19,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 13,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 65,
+ name: 'Proizvod 5 - Mega Market',
+ price: 29.55,
+ weight: 2.81,
+ weightunit: 'lbs',
+ volume: 2.97,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 13,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 66,
+ name: 'Proizvod 1 - Green Garden',
+ price: 71.32,
+ weight: 1.71,
+ weightunit: 'g',
+ volume: 0.29,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 14,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 67,
+ name: 'Proizvod 2 - Green Garden',
+ price: 37.28,
+ weight: 2.48,
+ weightunit: 'g',
+ volume: 1.15,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 14,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 68,
+ name: 'Proizvod 3 - Green Garden',
+ price: 53.29,
+ weight: 4.85,
+ weightunit: 'lbs',
+ volume: 0.52,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 14,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 69,
+ name: 'Proizvod 4 - Green Garden',
+ price: 54.9,
+ weight: 4.31,
+ weightunit: 'lbs',
+ volume: 2.09,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 14,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 70,
+ name: 'Proizvod 5 - Green Garden',
+ price: 6.86,
+ weight: 3.49,
+ weightunit: 'kg',
+ volume: 2.9,
+ volumeunit: 'L',
+ productcategoryid: 4,
+ storeId: 14,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 71,
+ name: 'Proizvod 1 - Kids Toys',
+ price: 87.8,
+ weight: 1.93,
+ weightunit: 'lbs',
+ volume: 0.27,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 15,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 72,
+ name: 'Proizvod 2 - Kids Toys',
+ price: 73.06,
+ weight: 2.18,
+ weightunit: 'kg',
+ volume: 2.15,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 15,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 73,
+ name: 'Proizvod 3 - Kids Toys',
+ price: 33.27,
+ weight: 4.64,
+ weightunit: 'kg',
+ volume: 2.64,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 15,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 74,
+ name: 'Proizvod 4 - Kids Toys',
+ price: 68.53,
+ weight: 2.69,
+ weightunit: 'g',
+ volume: 1.49,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 15,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 75,
+ name: 'Proizvod 5 - Kids Toys',
+ price: 31.55,
+ weight: 0.76,
+ weightunit: 'kg',
+ volume: 0.73,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 15,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 76,
+ name: 'Proizvod 1 - Mega Market',
+ price: 43.07,
+ weight: 2.92,
+ weightunit: 'g',
+ volume: 2.0,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 16,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 77,
+ name: 'Proizvod 2 - Mega Market',
+ price: 70.38,
+ weight: 2.2,
+ weightunit: 'kg',
+ volume: 1.75,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 16,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 78,
+ name: 'Proizvod 3 - Mega Market',
+ price: 69.44,
+ weight: 3.04,
+ weightunit: 'kg',
+ volume: 0.59,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 16,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 79,
+ name: 'Proizvod 4 - Mega Market',
+ price: 83.14,
+ weight: 4.55,
+ weightunit: 'kg',
+ volume: 0.95,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 16,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 80,
+ name: 'Proizvod 5 - Mega Market',
+ price: 91.73,
+ weight: 1.42,
+ weightunit: 'g',
+ volume: 1.98,
+ volumeunit: 'oz',
+ productcategoryid: 2,
+ storeId: 16,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 81,
+ name: 'Proizvod 1 - Green Garden',
+ price: 24.71,
+ weight: 0.48,
+ weightunit: 'g',
+ volume: 1.66,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 17,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 82,
+ name: 'Proizvod 2 - Green Garden',
+ price: 94.13,
+ weight: 2.36,
+ weightunit: 'kg',
+ volume: 1.75,
+ volumeunit: 'ml',
+ productcategoryid: 4,
+ storeId: 17,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 83,
+ name: 'Proizvod 3 - Green Garden',
+ price: 34.28,
+ weight: 3.54,
+ weightunit: 'lbs',
+ volume: 2.42,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 17,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 84,
+ name: 'Proizvod 4 - Green Garden',
+ price: 51.48,
+ weight: 4.33,
+ weightunit: 'g',
+ volume: 0.13,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 17,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 85,
+ name: 'Proizvod 5 - Green Garden',
+ price: 6.2,
+ weight: 2.18,
+ weightunit: 'g',
+ volume: 2.23,
+ volumeunit: 'oz',
+ productcategoryid: 4,
+ storeId: 17,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 86,
+ name: 'Proizvod 1 - Kids Toys',
+ price: 5.95,
+ weight: 4.27,
+ weightunit: 'lbs',
+ volume: 0.54,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 18,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 87,
+ name: 'Proizvod 2 - Kids Toys',
+ price: 88.59,
+ weight: 3.56,
+ weightunit: 'g',
+ volume: 2.99,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 18,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 88,
+ name: 'Proizvod 3 - Kids Toys',
+ price: 50.45,
+ weight: 0.34,
+ weightunit: 'g',
+ volume: 2.75,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 18,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 89,
+ name: 'Proizvod 4 - Kids Toys',
+ price: 28.4,
+ weight: 3.65,
+ weightunit: 'g',
+ volume: 0.34,
+ volumeunit: 'ml',
+ productcategoryid: 6,
+ storeId: 18,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 90,
+ name: 'Proizvod 5 - Kids Toys',
+ price: 99.99,
+ weight: 2.91,
+ weightunit: 'g',
+ volume: 0.81,
+ volumeunit: 'oz',
+ productcategoryid: 6,
+ storeId: 18,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 91,
+ name: 'Proizvod 1 - Mega Market',
+ price: 40.21,
+ weight: 2.1,
+ weightunit: 'kg',
+ volume: 1.77,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 19,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 92,
+ name: 'Proizvod 2 - Mega Market',
+ price: 26.51,
+ weight: 1.9,
+ weightunit: 'g',
+ volume: 2.62,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 19,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 93,
+ name: 'Proizvod 3 - Mega Market',
+ price: 69.04,
+ weight: 3.45,
+ weightunit: 'lbs',
+ volume: 1.45,
+ volumeunit: 'ml',
+ productcategoryid: 2,
+ storeId: 19,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 94,
+ name: 'Proizvod 4 - Mega Market',
+ price: 26.02,
+ weight: 4.57,
+ weightunit: 'kg',
+ volume: 1.27,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 19,
+ isActive: true,
+ photos: [],
+ },
+ {
+ id: 95,
+ name: 'Proizvod 5 - Mega Market',
+ price: 94.53,
+ weight: 0.5,
+ weightunit: 'lbs',
+ volume: 0.64,
+ volumeunit: 'L',
+ productcategoryid: 2,
+ storeId: 19,
+ isActive: true,
+ photos: [],
+ },
+];
+
+export default products;
diff --git a/src/data/stores.js b/src/data/stores.js
new file mode 100644
index 0000000..1141c71
--- /dev/null
+++ b/src/data/stores.js
@@ -0,0 +1,155 @@
+const stores = [
+ {
+ id: 1,
+ name: 'Nova Market',
+ description: 'Brza i kvalitetna dostava proizvoda.',
+ address: 'Sarajevo',
+ categoryId: 2,
+ categoryName: "kategorija2",
+ },
+ {
+ id: 2,
+ name: 'Tech World',
+ description: 'Elektronika i gadgeti.',
+ address: 'Mostar',
+ categoryId: 4,
+ categoryName: "kategorija4",
+ },
+ {
+ id: 3,
+ name: 'BioShop',
+ description: 'Prirodna kozmetika i hrana.',
+ address: 'Banja Luka',
+ categoryId: 6,
+ categoryName: "kategorija6",
+ },
+ {
+ id: 4,
+ name: 'Fashion Spot',
+ description: 'Savremena garderoba.',
+ address: 'Tuzla',
+ categoryId: 2,
+ categoryName: "kategorija2",
+ },
+ {
+ id: 5,
+ name: 'Office Plus',
+ description: 'Kancelarijski materijal i oprema.',
+ address: 'Sarajevo',
+ categoryId: 4,
+ categoryName: "kategorija4",
+ },
+ {
+ id: 6,
+ name: 'Auto Centar',
+ description: 'Dijelovi i oprema za automobile.',
+ address: 'Zenica',
+ categoryId: 6,
+ categoryName: "kategorija6",
+ },
+ {
+ id: 7,
+ name: 'Pet Planet',
+ description: 'Hrana i oprema za kućne ljubimce.',
+ address: 'Mostar',
+ categoryId: 2,
+ categoryName: "kategorija2",
+ },
+ {
+ id: 8,
+ name: 'Green Garden',
+ description: 'Sve za vašu baštu.',
+ address: 'Sarajevo',
+ categoryId: 4,
+ categoryName: "kategorija4",
+ },
+ {
+ id: 9,
+ name: 'Kids Toys',
+ description: 'Igračke i oprema za djecu.',
+ address: 'Sarajevo',
+ categoryId: 6,
+ categoryName: "kategorija6",
+ },
+ {
+ id: 10,
+ name: 'Mega Market',
+ description: 'Vaš svakodnevni supermarket.',
+ address: 'Banja Luka',
+ categoryId: 2,
+ categoryName: "kategorija2",
+ },
+ {
+ id: 11,
+ name: 'Green Garden',
+ description: 'Sve za vašu baštu.',
+ address: 'Sarajevo',
+ categoryId: 4,
+ categoryName: "kategorija4",
+ },
+ {
+ id: 12,
+ name: 'Kids Toys',
+ description: 'Igračke i oprema za djecu.',
+ address: 'Mostar',
+ categoryId: 6,
+ categoryName: "kategorija6",
+ },
+ {
+ id: 13,
+ name: 'Mega Market',
+ description: 'Vaš svakodnevni supermarket.',
+ address: 'Zenica',
+ categoryId: 2,
+ categoryName: "kategorija2",
+ },
+ {
+ id: 14,
+ name: 'Green Garden',
+ description: 'Sve za vašu baštu.',
+ address: 'Sarajevo',
+ categoryId: 4,
+ categoryName: "kategorija4",
+ },
+ {
+ id: 15,
+ name: 'Kids Toys',
+ description: 'Igračke i oprema za djecu.',
+ address: 'Tuzla',
+ categoryId: 6,
+ categoryName: "kategorija6",
+ },
+ {
+ id: 16,
+ name: 'Mega Market',
+ description: 'Vaš svakodnevni supermarket.',
+ address: 'Banja Luka',
+ categoryId: 2,
+ categoryName: "kategorija2",
+ },
+ {
+ id: 17,
+ name: 'Green Garden',
+ description: 'Sve za vašu baštu.',
+ address: 'Sarajevo',
+ categoryId: 4,
+ categoryName: "kategorija4",
+ },
+ {
+ id: 18,
+ name: 'Kids Toys',
+ description: 'Igračke i oprema za djecu.',
+ address: 'Mostar',
+ categoryId: 6,
+ categoryName: "kategorija6",
+ },
+ {
+ id: 19,
+ name: 'Mega Market',
+ description: 'Vaš svakodnevni supermarket.',
+ address: 'Tuzla',
+ categoryId: 2,
+ categoryName: "kategorija2",
+ },
+ ];
+ export default stores;
\ No newline at end of file
diff --git a/src/data/users.js b/src/data/users.js
new file mode 100644
index 0000000..db73851
--- /dev/null
+++ b/src/data/users.js
@@ -0,0 +1,185 @@
+// data/users.js
+
+import { fetchAdminUsers } from "../utils/users";
+
+let users = [
+ {
+ id: 1,
+ userName: "John Doe",
+ email: "john.doe@example.com",
+ roles: ["Seller"],
+ availability: "Online",
+ lastActive: "Now",
+ isApproved: true,
+ },
+ {
+ id: 2,
+ userName: "Jane Smith",
+ email: "jane.smith@example.com",
+ roles: ["Buyer"],
+ availability: "Online",
+ lastActive: "Now",
+ isApproved: true,
+ },
+ {
+ id: 3,
+ userName: "Alice Johnson",
+ email: "alice.johnson@example.com",
+ roles: ["Seller"],
+ availability: "Online",
+ lastActive: "Now",
+ isApproved: true,
+ },
+ {
+ id: 4,
+ userName: "Bob Brown",
+ email: "bob.brown@example.com",
+ roles: ["Buyer"],
+ availability: "Online",
+ lastActive: "2024-04-09, 14:30:00",
+ isApproved: true,
+ },
+ {
+ id: 5,
+ userName: "John Doe",
+ email: "john.doe@example.com",
+ roles: ["Seller"],
+ availability: "Offline",
+ lastActive: "2024-04-08, 13:15:00",
+ isApproved: true,
+ },
+ {
+ id: 6,
+ userName: "Jane Smith",
+ email: "jane.smith@example.com",
+ roles: ["Buyer"],
+ availability: "Offline",
+ lastActive: "2024-04-09, 14:30:00",
+ isApproved: true,
+ },
+ {
+ id: 7,
+ userName: "Alice Johnson",
+ email: "alice.johnson@example.com",
+ roles: ["Seller"],
+ availability: "Offline",
+ lastActive: "2024-04-07, 10:00:00",
+ isApproved: true,
+ },
+ {
+ id: 8,
+ userName: "Bob Brown",
+ email: "bob.brown@example.com",
+ roles: ["Buyer"],
+ availability: "Online",
+ lastActive: "Now",
+ isApproved: true,
+ },
+ {
+ id: 9,
+ userName: "John Doe",
+ email: "john.doe@example.com",
+ roles: ["Seller"],
+ availability: "Online",
+ lastActive: "2024-04-09, 09:45:00",
+ isApproved: true,
+ },
+ {
+ id: 10,
+ userName: "Jane Smith",
+ email: "jane.smith@example.com",
+ roles: ["Buyer"],
+ availability: "Offline",
+ lastActive: "2024-04-06, 17:20:00",
+ isApproved: true,
+ },
+ {
+ id: 11,
+ userName: "Alice Johnson",
+ email: "alice.johnson@example.com",
+ roles: ["Seller"],
+ availability: "Online",
+ lastActive: "2024-04-05, 14:00:00",
+ isApproved: true,
+ },
+ {
+ id: 12,
+ userName: "Bob Brown",
+ email: "bob.brown@example.com",
+ roles: ["Buyer"],
+ availability: "Offline",
+ lastActive: "2024-04-03, 11:00:00",
+ isApproved: true,
+ },
+ {
+ id: 13,
+ userName: "John Doe",
+ email: "john.doe@example.com",
+ roles: ["Seller"],
+ availability: "Online",
+ lastActive: "Now",
+ isApproved: true,
+ },
+ {
+ id: 14,
+ userName: "Jane Smith",
+ email: "jane.smith@example.com",
+ roles: ["Buyer"],
+ availability: "Online",
+ lastActive: "Now",
+ isApproved: true,
+ },
+ {
+ id: 15,
+ userName: "Alice Johnson",
+ email: "alice.johnson@example.com",
+ roles: ["Seller"],
+ availability: "Offline",
+ lastActive: "2024-04-01, 15:30:00",
+ isApproved: true,
+ },
+ {
+ id: 16,
+ userName: "Bob Brown",
+ email: "bob.brown@example.com",
+ roles: ["Buyer"],
+ availability: "Online",
+ lastActive: "Now",
+ isApproved: true,
+ },
+ {
+ id: 17,
+ userName: "Bob Brown",
+ email: "bob.brown@example.com",
+ roles: ["Buyer"],
+ availability: "Offline",
+ lastActive: "2024-04-02, 13:00:00",
+ isApproved: true,
+ },
+];
+
+
+// Funkcija za vraćanje svih korisnika
+export async function getUsers() {
+ console.log("getUsers pozvan");
+ //console.log("Trenutni users array:", users);
+ const users = await fetchAdminUsers();
+ return [...users];
+}
+
+// Funkcija za brisanje korisnika
+export function deleteUser(userId) {
+ users = users.filter((user) => user.id !== userId);
+}
+
+// Funkcija za pretragu korisnika
+export function searchUsers(searchTerm) {
+ return users.filter(
+ (user) =>
+ user.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ user.email.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+}
+
+
+export default users;
\ No newline at end of file
diff --git a/src/data/usersDetails.js b/src/data/usersDetails.js
new file mode 100644
index 0000000..69e04dd
--- /dev/null
+++ b/src/data/usersDetails.js
@@ -0,0 +1,16 @@
+import UserPhone from "../components/UserPhone";
+
+let users = [
+ { id: 1, name: "John Doe", email: "john.doe@example.com", role: "buyer" , phoneNumber: "060312589"},
+ { id: 2, name: "Jane Smith", email: "jane.smith@example.com", role: "seller", phoneNumber: "062312589"},
+ { id: 3, name: "Alice Johnson", email: "alice.johnson@example.com", role: "buyer", phoneNumber: "06031569"},
+ { id: 4, name: "Bob Brown", email: "bob.brown@example.com", role: "seller", phoneNumber: "061312589"}
+ ];
+
+
+ // Funkcija za ažuriranje korisnika
+ export function updateUser(userId, updatedUser) {
+ users = users.map(user =>
+ user.id === userId ? { ...user, ...updatedUser } : user
+ );
+ }
\ No newline at end of file
diff --git a/src/hooks/.gitkeep b/src/hooks/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/hooks/useAdSignalR.js b/src/hooks/useAdSignalR.js
new file mode 100644
index 0000000..7dc360b
--- /dev/null
+++ b/src/hooks/useAdSignalR.js
@@ -0,0 +1,198 @@
+import { useEffect, useRef, useState } from 'react';
+import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr';
+
+const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub';
+const baseUrl = import.meta.env.VITE_API_BASE_URL; // ili tvoj base url
+const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`;
+
+export function useAdSignalR() {
+ const connectionRef = useRef(null);
+ const [connectionStatus, setConnectionStatus] = useState('Disconnected');
+ const [latestAdUpdate, setLatestAdUpdate] = useState(null);
+ const [latestClickTime, setLatestClickTime] = useState(null);
+ const [latestViewTime, setLatestViewTime] = useState(null);
+ const [latestConversionTime, setLatestConversionTime] = useState(null);
+ const [adUpdatesHistory, setAdUpdatesHistory] = useState([]);
+
+ useEffect(() => {
+ const jwtToken = localStorage.getItem('token');
+ if (!jwtToken) {
+ setConnectionStatus('Auth Token Missing');
+ return;
+ }
+
+
+ const newConnection = new HubConnectionBuilder()
+ .withUrl(HUB_URL, {
+ accessTokenFactory: () => jwtToken,
+ })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(LogLevel.Information)
+ .build();
+
+ connectionRef.current = newConnection;
+ setConnectionStatus('Connecting...');
+
+ const startConnection = async () => {
+ try {
+ await newConnection.start();
+ setConnectionStatus('Connected');
+ } catch (err) {
+ setConnectionStatus('Error');
+ }
+ };
+
+ startConnection();
+
+ // Handlers
+ newConnection.on('ReceiveAdUpdate', (advertisement) => {
+ setLatestAdUpdate(advertisement);
+ setAdUpdatesHistory(prev => [
+ { type: 'Ad Update', data: advertisement, time: new Date() },
+ ...prev.slice(0, 9)
+ ]);
+ });
+
+ newConnection.on('ReceiveClickTimestamp', (timestamp) => {
+ setLatestClickTime(timestamp);
+ setAdUpdatesHistory(prev => [
+ { type: 'Click', data: timestamp, time: new Date() },
+ ...prev.slice(0, 9)
+ ]);
+ });
+
+ newConnection.on('ReceiveViewTimestamp', (timestamp) => {
+ setLatestViewTime(timestamp);
+ setAdUpdatesHistory(prev => [
+ { type: 'View', data: timestamp, time: new Date() },
+ ...prev.slice(0, 9)
+ ]);
+ });
+
+ newConnection.on('ReceiveConversionTimestamp', (timestamp) => {
+ setLatestConversionTime(timestamp);
+ setAdUpdatesHistory(prev => [
+ { type: 'Conversion', data: timestamp, time: new Date() },
+ ...prev.slice(0, 9)
+ ]);
+ });
+
+ newConnection.onclose(() => setConnectionStatus('Disconnected'));
+ newConnection.onreconnecting(() => setConnectionStatus('Reconnecting...'));
+ newConnection.onreconnected(() => setConnectionStatus('Connected'));
+
+ return () => {
+ if (connectionRef.current && connectionRef.current.state === 'Connected') {
+ connectionRef.current.stop();
+ }
+ };
+ }, []);
+
+ return {
+ connectionStatus,
+ latestAdUpdate,
+ latestClickTime,
+ latestViewTime,
+ latestConversionTime,
+ adUpdatesHistory,
+ };
+}
+
+
+export function useAdSignalRwithId(adId) {
+ const connectionRef = useRef(null);
+ const [connectionStatus, setConnectionStatus] = useState('Disconnected');
+ const [latestAdUpdate, setLatestAdUpdate] = useState(null);
+ const [latestClickTime, setLatestClickTime] = useState(null);
+ const [latestViewTime, setLatestViewTime] = useState(null);
+ const [latestConversionTime, setLatestConversionTime] = useState(null);
+ const [adUpdatesHistory, setAdUpdatesHistory] = useState([]);
+
+ useEffect(() => {
+ if (!adId) return;
+
+ const jwtToken = localStorage.getItem('token');
+ if (!jwtToken) {
+ setConnectionStatus('Auth Token Missing');
+ return;
+ }
+
+ const connection = new HubConnectionBuilder()
+ .withUrl(HUB_URL, {
+ accessTokenFactory: () => jwtToken,
+ })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(LogLevel.Information)
+ .build();
+
+ connectionRef.current = connection;
+ setConnectionStatus('Connecting...');
+
+ const startConnection = async () => {
+ try {
+ await connection.start();
+ setConnectionStatus('Connected');
+ } catch (err) {
+ console.error('SignalR Connection Error:', err);
+ setConnectionStatus('Error');
+ }
+ };
+
+ startConnection();
+
+ // === Filteruj po adId u svakom handleru ===
+ connection.on('ReceiveAdUpdate', (adUpdate) => {
+ if (adUpdate.id !== adId) return;
+ setLatestAdUpdate(adUpdate);
+ setAdUpdatesHistory(prev => [
+ { type: 'Ad Update', data: adUpdate, time: new Date() },
+ ...prev.slice(0, 9),
+ ]);
+ });
+
+ connection.on('ReceiveClickTimestamp', ({ adId: clickAdId, timestamp }) => {
+ if (clickAdId !== adId) return;
+ setLatestClickTime(timestamp);
+ setAdUpdatesHistory(prev => [
+ { type: 'Click', data: timestamp, time: new Date() },
+ ...prev.slice(0, 9),
+ ]);
+ });
+
+ connection.on('ReceiveViewTimestamp', ({ adId: viewAdId, timestamp }) => {
+ if (viewAdId !== adId) return;
+ setLatestViewTime(timestamp);
+ setAdUpdatesHistory(prev => [
+ { type: 'View', data: timestamp, time: new Date() },
+ ...prev.slice(0, 9),
+ ]);
+ });
+
+ connection.on('ReceiveConversionTimestamp', ({ adId: convAdId, timestamp }) => {
+ if (convAdId !== adId) return;
+ setLatestConversionTime(timestamp);
+ setAdUpdatesHistory(prev => [
+ { type: 'Conversion', data: timestamp, time: new Date() },
+ ...prev.slice(0, 9),
+ ]);
+ });
+
+ connection.onclose(() => setConnectionStatus('Disconnected'));
+ connection.onreconnecting(() => setConnectionStatus('Reconnecting...'));
+ connection.onreconnected(() => setConnectionStatus('Connected'));
+
+ return () => {
+ connection.stop();
+ };
+ }, [adId]);
+
+ return {
+ connectionStatus,
+ latestAdUpdate,
+ latestClickTime,
+ latestViewTime,
+ latestConversionTime,
+ adUpdatesHistory,
+ };
+}
+
diff --git a/src/hooks/useSignalR.js b/src/hooks/useSignalR.js
new file mode 100644
index 0000000..318b2ba
--- /dev/null
+++ b/src/hooks/useSignalR.js
@@ -0,0 +1,78 @@
+// @hooks/useSignalR.js
+import { useEffect, useRef, useState } from 'react';
+import * as signalR from '@microsoft/signalr';
+
+export const useSignalR = (conversationId, userId) => {
+ const [messages, setMessages] = useState([]);
+ const connectionRef = useRef(null);
+
+ useEffect(() => {
+ // Connect to SignalR
+ const connect = async () => {
+ const storedToken = localStorage.getItem('token');
+
+ if (!conversationId) {
+ console.error('Conversation ID is required for SignalR connection.');
+ return;
+ }
+
+ const connection = new signalR.HubConnectionBuilder()
+ .withUrl(`https://bazaar-system.duckdns.org/chathub`, {
+ accessTokenFactory: () => storedToken,
+ })
+ .withAutomaticReconnect()
+ .configureLogging(signalR.LogLevel.Information)
+ .build();
+
+ connection.serverTimeoutInMilliseconds = 60000;
+
+ // Listen for new messages
+ connection.on('ReceiveMessage', (receivedMessage) => {
+ setMessages((prevMessages) => [
+ ...prevMessages,
+ {
+ id: receivedMessage.id,
+ senderUserId: receivedMessage.senderUserId,
+ senderUsername: receivedMessage.senderUsername,
+ content: receivedMessage.content,
+ sentAt: receivedMessage.sentAt,
+ isOwnMessage: receivedMessage.senderUserId === userId,
+ },
+ ]);
+ });
+
+ try {
+ await connection.start();
+ console.log('SignalR connected');
+ connectionRef.current = connection;
+
+ // Join conversation-specific group
+ connection
+ .invoke('JoinConversation', conversationId)
+ .catch((err) => console.error('Error joining group:', err));
+ } catch (err) {
+ console.error('SignalR connection error:', err);
+ }
+ };
+
+ connect();
+
+ return () => {
+ connectionRef.current?.stop();
+ };
+ }, [conversationId, userId]);
+
+ // Send message to the SignalR hub
+ const sendMessage = (content) => {
+ if (connectionRef.current) {
+ connectionRef.current
+ .invoke('SendMessage', {
+ ConversationId: conversationId,
+ Content: content,
+ })
+ .catch((err) => console.error('Send failed:', err));
+ }
+ };
+
+ return { messages, sendMessage };
+};
diff --git a/src/i18n.js b/src/i18n.js
new file mode 100644
index 0000000..a64ba49
--- /dev/null
+++ b/src/i18n.js
@@ -0,0 +1,43 @@
+ import i18n from 'i18next';
+ import { initReactI18next } from 'react-i18next';
+ import HttpBackend from 'i18next-http-backend';
+ import LanguageDetector from 'i18next-browser-languagedetector';
+
+ // Predefined language codes
+ const PREDEFINED_LANG_CODES = ['en', 'es'];
+ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;
+
+ i18n
+ .use(HttpBackend)
+ .use(LanguageDetector)
+ .use(initReactI18next)
+ .init({
+ fallbackLng: 'en',
+ debug: process.env.NODE_ENV === 'development',
+ ns: ['translation'],
+ defaultNS: 'translation',
+ interpolation: {
+ escapeValue: false, // React already escapes values
+ },
+ backend: {
+ loadPath: `${API_BASE_URL}/api/translations/{{lng}}`
+ }
+ });
+
+ // Function to fetch and set supported languages
+ async function fetchAndSetSupportedLanguages() {
+ try {
+ const response = await fetch(`${API_BASE_URL}/api/translations/languages`);
+ const allLangsFromServer = await response.json();
+ i18n.options.supportedLngs = allLangsFromServer.map(l => l.code);
+ } catch (error) {
+ console.error('Failed to fetch supported languages:', error);
+ // Fallback to predefined languages if API call fails
+ i18n.options.supportedLngs = PREDEFINED_LANG_CODES;
+ }
+ }
+
+ // Call the function on startup
+ fetchAndSetSupportedLanguages();
+
+ export default i18n;
\ No newline at end of file
diff --git a/src/main.jsx b/src/main.jsx
index b9a1a6d..91296b9 100644
--- a/src/main.jsx
+++ b/src/main.jsx
@@ -1,10 +1,21 @@
-import { StrictMode } from 'react'
-import { createRoot } from 'react-dom/client'
-import './index.css'
-import App from './App.jsx'
+import React, { StrictMode } from "react";
+import AppRoutes from "@routes/Router";
+import { createRoot } from "react-dom/client";
+import { ThemeProvider } from "@mui/material/styles";
+import CssBaseline from "@mui/material/CssBaseline";
+import theme from "@styles/theme";
+import "./App.css";
+import "./index.css";
+import { PendingUsersProvider } from "./context/PendingUsersContext";
+import './i18n';
-createRoot(document.getElementById('root')).render(
+createRoot(document.getElementById("root")).render(
-
- ,
-)
+
+
+
+
+
+
+
+);
\ No newline at end of file
diff --git a/src/models/chatModels.js b/src/models/chatModels.js
new file mode 100644
index 0000000..5523d2b
--- /dev/null
+++ b/src/models/chatModels.js
@@ -0,0 +1,36 @@
+// @models/chatModels.js
+export const ChatMessageType = {
+ SENT: 'sent',
+ RECEIVED: 'received',
+};
+
+export class ConversationDto {
+ id = 0;
+ otherParticipantName = '';
+ lastMessageSnippet = '';
+ lastMessageTimestamp = '';
+ unreadMessagesCount = 0;
+ orderId = null;
+ storeId = null;
+}
+
+export class MessageDto {
+ id = 0;
+ senderUserId = '';
+ senderUsername = '';
+ content = '';
+ sentAt = '';
+ readAt = null;
+ isPrivate = false;
+}
+
+export class ChatMessage {
+ id = 0;
+ senderUserId = '';
+ senderUsername = '';
+ content = '';
+ sentAt = '';
+ readAt = null;
+ isPrivate = false;
+ isOwnMessage = false;
+}
diff --git a/src/pages/.gitkeep b/src/pages/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/pages/AdPage.jsx b/src/pages/AdPage.jsx
new file mode 100644
index 0000000..9041fce
--- /dev/null
+++ b/src/pages/AdPage.jsx
@@ -0,0 +1,203 @@
+import React, { useState, useEffect } from 'react';
+import { Box } from '@mui/material';
+import AdCard from '@components/AdCard';
+import AdsManagementHeader from '@sections/AdsManagementHeader';
+import UserManagementPagination from '@components/UserManagementPagination';
+import AddAdModal from '@components/AddAdModal';
+import AdvertisementDetailsModal from '@components/AdvertisementDetailsModal';
+import { useAdSignalR } from '../hooks/useAdSignalR'; // ili stvarna putanja
+import {
+ apiCreateAdAsync,
+ apiGetAllAdsAsync,
+ apiDeleteAdAsync,
+ apiUpdateAdAsync,
+ apiGetAllStoresAsync,
+ apiGetProductCategoriesAsync,
+} from '../api/api';
+import products from '../data/products';
+const generateMockAds = () => {
+ return Array.from({ length: 26 }, (_, i) => ({
+ id: i + 1,
+ sellerId: 42 + i,
+ Views: 1200 + i * 10,
+ Clicks: 300 + i * 5,
+ startTime: '2024-05-01T00:00:00Z',
+ endTime: '2024-06-01T00:00:00Z',
+ isActive: i % 2 === 0,
+ AdData: [
+ {
+ Description: `Ad Campaign #${i + 1}`,
+ Image: 'https://via.placeholder.com/150',
+ ProductLink: 'https://example.com/product',
+ StoreLink: 'https://example.com/store',
+ },
+ ],
+ }));
+};
+
+const AdPage = () => {
+ const [searchTerm, setSearchTerm] = useState('');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [ads, setAds] = useState([]);
+ const [stores, setStores] = useState([]);
+ const [selectedAd, setSelectedAd] = useState(null);
+
+ const { latestAdUpdate } = useAdSignalR();
+
+ const [isLoading, setIsLoading] = useState(true);
+
+ const adsPerPage = 5;
+
+ const filteredAds = ads.filter((ad) =>
+ ad.adData != undefined && ad.adData[0].description.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ const totalPages = Math.ceil(filteredAds.length / adsPerPage);
+ const paginatedAds = filteredAds.slice(
+ (currentPage - 1) * adsPerPage,
+ currentPage * adsPerPage
+ );
+
+ useEffect(() => {
+ async function fetchAllAdData() {
+ setIsLoading(true);
+ try{
+ const rez = await apiGetAllAdsAsync();
+ const stores = await apiGetAllStoresAsync();
+ const productCategories = await apiGetProductCategoriesAsync();
+ setAds(rez.data);
+ setStores(stores);
+ } catch (err) {
+ console.error("Greška pri dohvaćanju reklama:", err);
+ }
+ setIsLoading(false);
+ };
+
+ fetchAllAdData();
+ }, []);
+
+ // === 2. Real-time update preko SignalR ===
+ useEffect(() => {
+ if (latestAdUpdate) {
+ setAds((prevAds) =>
+ prevAds.map((ad) =>
+ ad.id === latestAdUpdate.id
+ ? {
+ ...ad,
+ views: latestAdUpdate.views,
+ clicks: latestAdUpdate.clicks,
+ }
+ : ad
+ )
+ );
+ }
+ }, [latestAdUpdate]);
+
+ const handleDelete = async (id) => {
+ const response = await apiDeleteAdAsync(id);
+ console.log("nesto");
+ const res = await apiGetAllAdsAsync();
+ setAds(res.data);
+
+ };
+
+ const handleEdit = async (adId, payload) => {
+ try {
+ const response = await apiUpdateAdAsync(adId, payload);
+ if (response.status < 400) {
+ const updated = await apiGetAllAdsAsync();
+ setAds(updated.data);
+ } else {
+ console.error('Failed to update advertisement');
+ }
+ } catch (error) {
+ console.error('Error updating ad:', error);
+ }
+ };
+
+ const handleViewDetails = (id) => {
+ const found = ads.find((a) => a.id === id);
+ console.log("detalji");
+ setSelectedAd(found);
+ };
+
+ const handleCreateAd = () => {
+ setIsModalOpen(true);
+ };
+
+const handleAddAd = async (newAd) => {
+ try {
+ const response = await apiCreateAdAsync(newAd);
+ if (response.status < 400 && response.data) {
+ setAds(prev => [...prev, response.data]);
+ console.log("Uradjeno");
+ setIsModalOpen(false);
+ } else {
+ console.error('Greška pri kreiranju oglasa:', response);
+ }
+ } catch (error) {
+ console.error('API error:', error);
+ }
+};
+
+ const handlePageChange = (page) => {
+ setCurrentPage(page);
+ };
+
+ return (
+
+
+
+
+ {paginatedAds.map((ad) => (
+
+
+
+ ))}
+
+
+
+
+ setIsModalOpen(false)}
+ onAddAd={handleAddAd}
+ />
+
+ setSelectedAd(null)}
+ onDelete={handleDelete}
+ onSave={handleEdit}
+ />
+
+ );
+};
+
+export default AdPage;
diff --git a/src/pages/AnalyticsPage.jsx b/src/pages/AnalyticsPage.jsx
new file mode 100644
index 0000000..e71ec91
--- /dev/null
+++ b/src/pages/AnalyticsPage.jsx
@@ -0,0 +1,874 @@
+import React from 'react';
+import { Grid, Typography, Box, Pagination } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import KpiCard from '@components/KpiCard';
+import AnalyticsChart from '@components/AnalyticsChart';
+import CountryStatsPanel from '@components/CountryStatsPanel';
+import OrdersByStatus from '@components/OrdersByStatus';
+import UserDistribution from '@components/UserDistribution';
+import RevenueByStore from '@components/RevenueByStore';
+// --- Merged Imports ---
+import ProductsSummary from '@components/ProductsSummary'; // From HEAD
+import RevenueMetrics from '@components/RevenueMetrics'; // From HEAD
+import ParetoChart from '@components/ParetoChart'; // From develop
+import AdFunnelChart from '@components/AdFunnelChart'; // From develop
+import AdStackedBarChart from '@components/AdStackedBarChart'; // From develop
+import Calendar from '@components/Calendar'; // From develop
+import DealsChart from '@components/DealsChart'; // From develop
+import SalesChart from '@components/SalesChart'; // From develop
+import { useState, useEffect, useRef } from 'react'; // useRef from develop
+import StoreEarningsTable from '@components/StoreEarningsTable';
+
+import {
+ apiGetAllAdsAsync,
+ apiFetchOrdersAsync, // Used in develop's fetchInitialData, not in HEAD's kpis
+ apiFetchAllUsersAsync, // Used in develop's fetchInitialData, not in HEAD's kpis
+ apiGetAllStoresAsync,
+ apiGetStoreProductsAsync,
+ apiFetchAdsWithProfitAsync,
+ apiFetchAdClicksAsync,
+ apiFetchAdViewsAsync,
+ apiFetchAdConversionsAsync, // From HEAD, for ProductsSummary
+ apiGetStoreIncomeAsync,
+ apiGetMonthlyStoreRevenueAsync
+} from '../api/api.js';
+// format and parseISO were in develop but not used in the conflicting part, subMonths is used by both
+import { subMonths, format, parseISO } from 'date-fns'; // Added format, parseISO from develop imports
+import { HubConnectionBuilder, LogLevel } from '@microsoft/signalr'; // From develop
+import SellerAnalytics from './SellerAnalyticsPage.jsx';
+
+// --- SignalR Setup (from develop) ---
+const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
+const HUB_ENDPOINT_PATH = '/Hubs/AdvertisementHub';
+const HUB_URL = `${baseUrl}${HUB_ENDPOINT_PATH}`;
+
+const AnalyticsPage = () => {
+ const { t } = useTranslation();
+ // --- State from develop ---
+ const [totalAdminProfit, setTotalAdminProfit] = useState(0);
+ const [ads, setAds] = useState([]); // For general ad data, updated by SignalR
+ const [kpi, setKpi] = useState({
+ totalViews: 0,
+ totalClicks: 0,
+ totalConversions: 0,
+ totalConversionRevenue: 0,
+ totalAds: 0,
+ activeAds: 0,
+ topAds: [],
+ totalClicksRevenue: 0,
+ totalViewsRevenue: 0,
+ totalProducts: 0,
+ viewsChange: 0,
+ clicksChange: 0,
+ conversionsChange: 0,
+ conversionRevenueChange: 0,
+ clicksRevenueChange: 0,
+ viewsRevenueChange: 0,
+ productsChange: 0, // Will be set based on logic
+ totalAdsChange: 0,
+ });
+ const [connectionStatus, setConnectionStatus] = useState('Disconnected');
+ const [lastError, setLastError] = useState('');
+ const connectionRef = useRef(null);
+ const [clickTimeStamps, setClickTimeStamps] = useState([]);
+ const [viewTimeStamps, setViewTimeStamps] = useState([]);
+ const [conversionTimeStamps, setConversionTimeStamps] = useState([]);
+ const [realtimeEvents, setRealtimeEvents] = useState([]);
+
+ // --- State from HEAD (for product pagination and summary) ---
+ const [products, setProducts] = useState([]); // For paginated product list
+ const [adsDataForSummary, setAdsDataForSummary] = useState([]); // Specifically for ProductsSummary
+ const [currentProductPage, setCurrentProductPage] = useState(1);
+ const PRODUCTS_PER_PAGE = 5; // Or your desired number
+
+ const [stores, setStores] = useState([]);
+ // const [adsDataForStoreSummary, setAdsDataForStoreSummary] = useState([]); // This might not be needed if ads state is comprehensive
+ const [currentStorePage, setCurrentStorePage] = useState(1);
+ const STORES_PER_PAGE = 1;
+
+ const [storeSpecificClickData, setStoreSpecificClickData] = useState([]);
+ const [storeSpecificViewData, setStoreSpecificViewData] = useState([]);
+ const [storeSpecificConversionData, setStoreSpecificConversionData] =
+ useState([]);
+
+ const [storeStats, setStoreStats] = useState([]);
+
+
+ // --- Pagination Logic (from HEAD) ---
+ const handlePageChange = (event, value) => {
+ setCurrentProductPage(value);
+ console.log(`curr:${currentProductPage} value${value}`);
+ console.log(paginatedProducts);
+ paginatedProducts = products.slice(
+ (value - 1) * PRODUCTS_PER_PAGE,
+ value * PRODUCTS_PER_PAGE
+ );
+ };
+
+ var paginatedProducts = products.slice(
+ (currentProductPage - 1) * PRODUCTS_PER_PAGE,
+ currentProductPage * PRODUCTS_PER_PAGE
+ );
+ const pageCount = Math.ceil(products.length / PRODUCTS_PER_PAGE);
+
+ const handleStorePageChange = (event, value) => {
+ setCurrentStorePage(value);
+ paginatedStores = [stores[value - 1]];
+ // paginatedStores will be derived directly in render or useEffect based on currentStorePage
+ };
+
+ let paginatedStores = [stores[0]];
+ const storePageCount = Math.ceil(stores.length / STORES_PER_PAGE);
+
+ // --- SignalR useEffect (from develop) ---
+ useEffect(() => {
+ const jwtToken = localStorage.getItem('token');
+ if (!jwtToken) {
+ console.warn(
+ 'AnalyticsPage: No JWT token found. SignalR connection not started.'
+ );
+ setConnectionStatus('Auth Token Missing');
+ return;
+ }
+ const newConnection = new HubConnectionBuilder()
+ .withUrl(HUB_URL, { accessTokenFactory: () => jwtToken })
+ .withAutomaticReconnect([0, 2000, 10000, 30000])
+ .configureLogging(LogLevel.Information)
+ .build();
+ connectionRef.current = newConnection;
+ setConnectionStatus('Connecting...');
+
+ const startConnection = async () => {
+ try {
+ await newConnection.start();
+ console.log('SignalR Connected to AdvertisementHub!');
+ setConnectionStatus('Connected');
+ setLastError('');
+ } catch (err) {
+ console.error('SignalR Connection Error: ', err);
+ setConnectionStatus(
+ `Error: ${err.message ? err.message.substring(0, 150) : 'Unknown'}`
+ );
+ setLastError(err.message || 'Failed to connect');
+ }
+ };
+ startConnection();
+
+ newConnection.on('ReceiveAdUpdate', (advertisement) => {
+ console.log('Received Ad Update:', advertisement);
+ setAds((prevAds) => {
+ const adIndex = prevAds.findIndex((ad) => ad.id === advertisement.id);
+ const updatedAds = [...prevAds];
+ if (adIndex !== -1) updatedAds[adIndex] = advertisement;
+ else updatedAds.unshift(advertisement);
+ calculateKpis(updatedAds, kpi.totalProducts); // Recalculate KPIs
+ return updatedAds;
+ });
+ setRealtimeEvents((prev) => [
+ { type: 'Ad Update', data: advertisement, time: new Date() },
+ ...prev.slice(0, 19),
+ ]);
+ });
+ newConnection.on('ReceiveClickTimestamp', (timestamp) => {
+ console.log('Received Click Timestamp:', timestamp);
+ setClickTimeStamps((prev) => [...prev, timestamp]);
+ setRealtimeEvents((prev) => [
+ {
+ type: 'Click',
+ data: new Date(timestamp).toLocaleTimeString(),
+ time: new Date(),
+ },
+ ...prev.slice(0, 19),
+ ]);
+ });
+ newConnection.on('ReceiveViewTimestamp', (timestamp) => {
+ console.log('Received View Timestamp:', timestamp);
+ setViewTimeStamps((prev) => [...prev, timestamp]);
+ setRealtimeEvents((prev) => [
+ {
+ type: 'View',
+ data: new Date(timestamp).toLocaleTimeString(),
+ time: new Date(),
+ },
+ ...prev.slice(0, 19),
+ ]);
+ });
+ newConnection.on('ReceiveConversionTimestamp', (timestamp) => {
+ console.log('Received Conversion Timestamp:', timestamp);
+ setConversionTimeStamps((prev) => [...prev, timestamp]);
+ setRealtimeEvents((prev) => [
+ {
+ type: 'Conversion',
+ data: new Date(timestamp).toLocaleTimeString(),
+ time: new Date(),
+ },
+ ...prev.slice(0, 19),
+ ]);
+ });
+ newConnection.onclose((error) => {
+ console.warn('SignalR connection closed.', error);
+ setConnectionStatus('Disconnected');
+ if (error) setLastError(`Connection closed: ${error.message}`);
+ });
+ newConnection.onreconnecting((error) => {
+ console.warn('SignalR attempting to reconnect...', error);
+ setConnectionStatus('Reconnecting...');
+ setLastError(error ? `Reconnect failed: ${error.message}` : '');
+ });
+ newConnection.onreconnected((connectionId) => {
+ console.log('SignalR reconnected with ID:', connectionId);
+ setConnectionStatus('Connected');
+ setLastError('');
+ });
+ return () => {
+ if (
+ connectionRef.current &&
+ connectionRef.current.state === 'Connected'
+ ) {
+ console.log('Stopping SignalR connection.');
+ connectionRef.current
+ .stop()
+ .catch((err) => console.error('Error stopping SignalR:', err));
+ }
+ };
+ }, []); // Run once
+
+ // --- Initial Data Fetch (combining logic from both branches) ---
+ useEffect(() => {
+ fetchInitialData();
+ }, []);
+
+ const fetchInitialData = async () => {
+ try {
+ // Fetch stores first to get products
+ const stores = await apiGetAllStoresAsync();
+ setStores(stores);
+ let allFetchedProducts = [];
+ let productsThisMonthCount = 0;
+ let productsPrevMonthCount = 0;
+ const now = new Date();
+ const lastMonthDate = subMonths(now, 1);
+ const prevMonthDate = subMonths(now, 2);
+
+ if (stores && stores.length > 0) {
+ const productPromises = stores.map((store) =>
+ store && store.id
+ ? apiGetStoreProductsAsync(store.id)
+ : Promise.resolve({ data: [] })
+ );
+ const productResults = await Promise.all(productPromises);
+ productResults.forEach((result) => {
+ if (result && result.data && Array.isArray(result.data)) {
+ allFetchedProducts.push(...result.data);
+ result.data.forEach((p) => {
+ const createdAt = p.createdAt
+ ? parseISO(p.createdAt)
+ : new Date(0);
+ if (createdAt >= lastMonthDate) productsThisMonthCount++;
+ if (createdAt >= prevMonthDate && createdAt < lastMonthDate)
+ productsPrevMonthCount++;
+ });
+ }
+ });
+ }
+ setProducts(allFetchedProducts); // For product pagination
+
+ const calculatedProductsChange =
+ productsPrevMonthCount > 0
+ ? ((productsThisMonthCount - productsPrevMonthCount) /
+ productsPrevMonthCount) *
+ 100
+ : productsThisMonthCount > 0
+ ? 100
+ : 0;
+
+ // Fetch ads for general KPIs (from develop)
+ const adsResponse = await apiGetAllAdsAsync();
+ const initialAdsData =
+ adsResponse && adsResponse.data && Array.isArray(adsResponse.data)
+ ? adsResponse.data
+ : [];
+ console.log('Initial Ads Data:', initialAdsData);
+ setAds(initialAdsData);
+
+ const clickidk = [];
+ for (const ad of ads) {
+ const r = (await apiFetchAdClicksAsync(ad.id)).data;
+ clickidk.push({ id: ad.id, clicks: r });
+ }
+ setStoreSpecificClickData(clickidk);
+ const viewidk = [];
+ for (const ad of ads) {
+ const r = (await apiFetchAdViewsAsync(ad.id)).data;
+ viewidk.push({ id: ad.id, views: r });
+ }
+ setStoreSpecificViewData(viewidk);
+ const ccidk = [];
+ for (const ad of ads) {
+ const r = (await apiFetchAdConversionsAsync(ad.id)).data;
+ ccidk.push({ id: ad.id, conversions: r });
+ }
+ setStoreSpecificConversionData(ccidk);
+
+ // Fetch ads with profit for ProductsSummary (from HEAD)
+ const adsWithProfitResponse = await apiFetchAdsWithProfitAsync();
+ const adsForSummaryData =
+ adsWithProfitResponse && Array.isArray(adsWithProfitResponse)
+ ? adsWithProfitResponse
+ : adsWithProfitResponse && Array.isArray(adsWithProfitResponse.data)
+ ? adsWithProfitResponse.data
+ : [];
+ console.log('✅ Fetched ads with profit for summary:', adsForSummaryData);
+ setAdsDataForSummary(adsForSummaryData);
+
+ // Calculate KPIs with fetched data
+ // Pass allFetchedProducts.length for totalProductsCount
+ // Pass calculatedProductsChange for productsChange KPI
+ calculateKpis(
+ initialAdsData,
+ allFetchedProducts.length,
+ calculatedProductsChange
+ );
+
+
+
+ const earningsStats = await Promise.all(
+ stores.map(async (store) => {
+ try {
+ const income = await apiGetMonthlyStoreRevenueAsync(store.id);
+ console.log("ispis "+income.storeId+" total income "+income.totalIncome+" taxed income "+income.taxedIncome);// 👈 nova funkcija
+ return {
+ storeId: income.storeId,
+ name: income.storeName,
+ storeRevenue: income.totalIncome,
+ adminProfit: income.taxedIncome,
+ taxRate: (store.tax) * 100,
+ };
+ } catch (err) {
+ console.error(`❌ Error fetching income for store ${store.id}`, err);
+ return {
+ storeId: store.id,
+ name: store.name,
+ storeRevenue: 999,
+ adminProfit: 999,
+ taxRate: (store.tax) * 100,
+ };
+ }
+ })
+ );
+
+ setStoreStats(earningsStats);
+
+
+ // Other initial data if needed (orders, users - not directly used for KPIs in develop's version)
+ // const ordersData = await apiFetchOrdersAsync();
+ // const usersResponse = await apiFetchAllUsersAsync();
+ // const users = usersResponse.data;
+ } catch (error) {
+ console.error('Error fetching initial data:', error);
+ // Set error states or default values if needed
+ }
+ };
+
+ // --- KPI Calculation (from develop, adapted) ---
+ const calculateKpis = (
+ currentAdsData,
+ totalProductsCount,
+ productsChangeValue = kpi.productsChange
+ ) => {
+ const now = new Date();
+ const currentMonthStart = new Date(now.getFullYear(), now.getMonth(), 1);
+ const previousMonthStart = subMonths(currentMonthStart, 1); // Correctly get first day of previous month
+ const previousMonthEnd = subMonths(now, 1); // End of previous month is last day of previous month
+ previousMonthEnd.setDate(0); // Set to last day of previous month. Example: if now is July 10, this becomes June 30.
+ // More robust way: const previousMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
+
+ const adsThisMonth = currentAdsData.filter((ad) => {
+ const startTime = ad.startTime ? parseISO(ad.startTime) : new Date(0);
+ return startTime >= currentMonthStart && startTime <= now;
+ });
+ const adsPrevMonth = currentAdsData.filter((ad) => {
+ const startTime = ad.startTime ? parseISO(ad.startTime) : new Date(0);
+ return startTime >= previousMonthStart && startTime <= previousMonthEnd;
+ });
+
+ const calculateMetricAndChange = (metricExtractor, priceField = null) => {
+ const currentMonthTotal = adsThisMonth.reduce(
+ (sum, ad) =>
+ sum +
+ (priceField
+ ? (ad[metricExtractor] || 0) * (ad[priceField] || 0)
+ : ad[metricExtractor] || 0),
+ 0
+ );
+ const prevMonthTotal = adsPrevMonth.reduce(
+ (sum, ad) =>
+ sum +
+ (priceField
+ ? (ad[metricExtractor] || 0) * (ad[priceField] || 0)
+ : ad[metricExtractor] || 0),
+ 0
+ );
+ const change =
+ prevMonthTotal > 0
+ ? ((currentMonthTotal - prevMonthTotal) / prevMonthTotal) * 100
+ : currentMonthTotal > 0
+ ? 100
+ : 0;
+ return { total: currentMonthTotal, change };
+ };
+
+ const viewsStats = calculateMetricAndChange('views');
+ const clicksStats = calculateMetricAndChange('clicks');
+ const conversionsStats = calculateMetricAndChange('conversions');
+ const conversionRevenueStats = calculateMetricAndChange(
+ 'conversions',
+ 'conversionPrice'
+ ); // Assuming conversionPrice is per conversion
+ const clicksRevenueStats = calculateMetricAndChange('clicks', 'clickPrice');
+ const viewsRevenueStats = calculateMetricAndChange('views', 'viewPrice');
+
+ const totalAdsChange =
+ adsPrevMonth.length > 0
+ ? ((adsThisMonth.length - adsPrevMonth.length) / adsPrevMonth.length) *
+ 100
+ : adsThisMonth.length > 0
+ ? 100
+ : 0;
+
+ const activeAds = currentAdsData.filter((ad) => ad.isActive).length;
+ const topAds = [...currentAdsData]
+ .sort(
+ (a, b) =>
+ (b.conversions || 0) * (b.conversionPrice || 0) -
+ (a.conversions || 0) * (a.conversionPrice || 0)
+ ) // Sort by total conversion revenue
+ .slice(0, 5);
+
+ setKpi({
+ totalViews: viewsStats.total,
+ totalClicks: clicksStats.total,
+ totalConversions: conversionsStats.total,
+ totalConversionRevenue: conversionRevenueStats.total.toFixed(2),
+ totalAds: currentAdsData.length,
+ activeAds: activeAds,
+ topAds: topAds,
+ totalClicksRevenue: clicksRevenueStats.total.toFixed(2),
+ totalViewsRevenue: viewsRevenueStats.total.toFixed(2),
+ totalProducts: totalProductsCount,
+ viewsChange: viewsStats.change.toFixed(2),
+ clicksChange: clicksStats.change.toFixed(2),
+ conversionsChange: conversionsStats.change.toFixed(2),
+ conversionRevenueChange: conversionRevenueStats.change.toFixed(2),
+ clicksRevenueChange: clicksRevenueStats.change.toFixed(2),
+ viewsRevenueChange: viewsRevenueStats.change.toFixed(2),
+ productsChange: productsChangeValue.toFixed(2),
+ totalAdsChange: totalAdsChange.toFixed(2),
+ });
+ };
+
+ // RealtimeEventsList component (from develop, optional to render)
+ const RealtimeEventsList = () => (
+
+
+ Realtime Events ({connectionStatus})
+
+ {lastError && (
+
+ Last Error: {lastError}
+
+ )}
+ {realtimeEvents.length === 0 ? (
+
+ No events received yet
+
+ ) : (
+ realtimeEvents.map((event, index) => (
+
+
+ {event.type}
+
+
+ {new Date(event.time).toLocaleTimeString()}
+
+ {/* {JSON.stringify(event.data)} */}
+
+ ))
+ )}
+
+ );
+
+ return (
+
+
+ {t('analytics.dashboardAnalytics')}
+
+ ({connectionStatus})
+
+
+
+ {/* KPI sekcija (from develop) */}
+
+ {' '}
+ {/* Adjusted spacing and maxWidth */}
+ {[
+ {
+ label: t('analytics.totalAds'),
+ value: kpi.totalAds,
+ change: kpi.totalAdsChange,
+ type: 'totalAds',
+ },
+ {
+ label: t('analytics.totalViews'),
+ value: kpi.totalViews,
+ change: kpi.viewsChange,
+ type: 'views',
+ },
+ {
+ label: t('analytics.totalClicks'),
+ value: kpi.totalClicks,
+ change: kpi.clicksChange,
+ type: 'clicks',
+ },
+ {
+ label: t('analytics.totalConversions'),
+ value: kpi.totalConversions,
+ change: kpi.conversionsChange,
+ type: 'conversions',
+ },
+ {
+ label: t('analytics.conversionRevenue'),
+ value: kpi.totalConversionRevenue,
+ change: kpi.conversionRevenueChange,
+ type: 'conversionRevenue',
+ },
+ {
+ label: t('analytics.clicksRevenue'),
+ value: kpi.totalClicksRevenue,
+ change: kpi.clicksRevenueChange,
+ type: 'clicksRevenue',
+ },
+ {
+ label: t('analytics.viewsRevenue'),
+ value: kpi.totalViewsRevenue,
+ change: kpi.viewsRevenueChange,
+ type: 'viewsRevenue',
+ },
+ {
+ label: t('analytics.totalProducts'),
+ value: kpi.totalProducts,
+ change: kpi.productsChange,
+ type: 'products',
+ },
+ ].map((item, i) => (
+
+ {' '}
+ {/* Responsive grid items for KPIs */}
+
+
+ ))}
+
+
+ {/* Glavni graf + countries */}
+
+
+
+ {' '}
+ {/* Responsive width */}
+
+
+
+
+
+ {' '}
+ {/* Responsive width */}
+
+ {/* You might want to place RealtimeEventsList here or elsewhere */}
+ {/* */}
+
+
+
+
+
+ {' '}
+ {/* Responsive width */}
+
+
+ {' '}
+ {/* Responsive width */}
+
+
+
+
+
+ {' '}
+ {/* Responsive width */}
+
+
+
+
+
+ {' '}
+ {/* Responsive width */}
+
+
+
+
+
+ {/* Charts from develop */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/* Product List with Pagination (from HEAD) */}
+
+ {' '}
+ {/* Responsive width */}
+
+ {t('analytics.productPerformance')}
+
+ {products.length === 0 && (
+
+ {t('analytics.noProductsToDisplay')}
+
+ )}
+ {paginatedProducts.map((product, i) => (
+
+
+ {/* Ensure adsDataForSummary is correctly populated and passed */}
+
+ a.adData.map((b) => b.productId).includes(product.id)
+ )}
+ />
+
+
+ ))}
+ {pageCount > 1 && (
+
+
+
+ )}
+
+
+ {/* Store List with Pagination (from HEAD) */}
+
+
+ {t('analytics.storePerformance')}
+
+ {stores.length === 0 && (
+
+ {t('analytics.noStoresToDisplay')}
+
+ )}
+ {stores.length &&
+ [stores[currentStorePage - 1]].map((store, i) => (
+
+
+ {/* Ensure adsDataForSummary is correctly populated and passed */}
+ ad.adData?.[0]?.storeId === store.id)}
+ products={products.filter((p) => p.storeId == store.id)}
+ allClicks={storeSpecificClickData.filter((c) =>
+ ads
+ .filter((ad) => ad.adData[0].storeId == store.id)
+ .map((ad) => ad.id)
+ .includes(c.id)
+ )}
+ allViews={storeSpecificViewData.filter((c) =>
+ ads
+ .filter((ad) => ad.adData[0].storeId == store.id)
+ .map((ad) => ad.id)
+ .includes(c.id)
+ )}
+ allConversions={storeSpecificConversionData.filter((c) =>
+ ads
+ .filter((ad) => ad.adData[0].storeId == store.id)
+ .map((ad) => ad.id)
+ .includes(c.id)
+ )}
+ />
+
+
+ ))}
+ {pageCount > 1 && (
+
+
+
+ )}
+
+
+
+
+ {t('analytics.storeEarningsPastMonth')}
+
+
+
+
+
+
+ {/* //jel ovo ima smisla ovd? (Comment from HEAD)
+ // If RevenueMetrics is global, it should be outside this map.
+ // If it's per-product, it should receive 'product' as a prop. */}
+ {/* Assuming it might be per product */}
+
+
+ );
+};
+
+export default AnalyticsPage;
diff --git a/src/pages/CategoriesPage.jsx b/src/pages/CategoriesPage.jsx
new file mode 100644
index 0000000..c6381f5
--- /dev/null
+++ b/src/pages/CategoriesPage.jsx
@@ -0,0 +1,167 @@
+import React, { useState, useEffect } from "react";
+import { Box } from "@mui/material";
+import CategoriesHeader from "../sections/CategoriesHeader.jsx";
+import CategoryCard from "../components/CategoryCard.jsx";
+import UserManagementPagination from "../components/UserManagementPagination.jsx";
+import AddCategoryModal from "../components/AddCategoryModal";
+import {
+ apiGetProductCategoriesAsync,
+ apiGetStoreCategoriesAsync,
+ apiDeleteProductCategoryAsync,
+ apiDeleteStoreCategoryAsync,
+ apiAddProductCategoryAsync,
+ apiAddStoreCategoryAsync,
+ apiUpdateProductCategoryAsync,
+ apiUpdateStoreCategoryAsync,
+} from "@api/api.js";
+import CategoryTabs from "@components/CategoryTabs";
+
+const CategoriesPage = () => {
+ const [currentPage, setCurrentPage] = useState(1);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [allCategories, setAllCategories] = useState([]);
+ const [openModal, setOpenModal] = useState(false);
+ const [selectedType, setSelectedType] = useState("product");
+ const categoriesPerPage = 20;
+
+ useEffect(() => {
+ const fetchCategories = async () => {
+ const data =
+ selectedType === "product"
+ ? await apiGetProductCategoriesAsync()
+ : await apiGetStoreCategoriesAsync();
+
+ const enriched = data.map((cat) => ({ ...cat, type: selectedType }));
+
+ console.log(enriched)
+
+ setAllCategories(enriched);
+ };
+
+ fetchCategories();
+}, [selectedType]);
+
+
+ const handleOpenModal = () => {
+ setOpenModal(true);
+ };
+
+ const handleCloseModal = () => {
+ setOpenModal(false);
+ };
+
+ const handleAddCategory = async (newCategory) => {
+ let response;
+ console.log(newCategory.type)
+ if (newCategory.type === "product") {
+ response = await apiAddProductCategoryAsync(newCategory.name);
+ } else {
+ response = await apiAddStoreCategoryAsync(newCategory.name);
+ }
+
+ if (response?.success) {
+ if (newCategory.type === selectedType) {
+ setAllCategories((prev) => [...prev, response.data]);
+ }
+ }
+};
+
+
+ const handleUpdateCategory = async (updatedCategory) => {
+ const response = selectedType === "product"
+ ? await apiUpdateProductCategoryAsync(updatedCategory)
+ : await apiUpdateStoreCategoryAsync(updatedCategory);
+
+ if (response?.success) {
+ setAllCategories((prevCategories) =>
+ prevCategories.map((category) =>
+ category.id === updatedCategory.id ? updatedCategory : category
+ )
+ );
+ }
+};
+
+
+ const handleDeleteCategory = async (categoryId) => {
+ let response;
+ if (selectedType === "product") {
+ response = await apiDeleteProductCategoryAsync(categoryId);
+ } else {
+ response = await apiDeleteStoreCategoryAsync(categoryId);
+ }
+
+ if (response?.success || response?.status === 204) {
+ setAllCategories((prev) => prev.filter((cat) => cat.id !== categoryId));
+ }
+};
+
+ const filteredCategories = allCategories.filter((category) =>
+ category.name.toLowerCase().includes(searchTerm.toLowerCase())
+);
+
+ const totalPages = Math.ceil(filteredCategories.length / categoriesPerPage);
+ const indexOfLastCategory = currentPage * categoriesPerPage;
+ const indexOfFirstCategory = indexOfLastCategory - categoriesPerPage;
+ const currentCategories = filteredCategories.slice(
+ indexOfFirstCategory,
+ indexOfLastCategory
+ );
+
+ return (
+
+
+
+
+
+ {currentCategories.map((category) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CategoriesPage;
diff --git a/src/pages/ChatPage.jsx b/src/pages/ChatPage.jsx
new file mode 100644
index 0000000..79fde63
--- /dev/null
+++ b/src/pages/ChatPage.jsx
@@ -0,0 +1,81 @@
+// ChatPage.jsx
+import React, { useState, useEffect } from 'react';
+import { Box } from '@mui/material';
+import TicketListSection from '@components/TicketListSection';
+import AdminChatSection from '@sections/AdminChatSection';
+import {
+ apiFetchAllTicketsAsync,
+ apiFetchAllConversationsAsync,
+} from '../api/api.js';
+
+export default function ChatPage() {
+ const [tickets, setTickets] = useState([]);
+ const [selectedTicketId, setSelectedTicketId] = useState(null);
+ const [unlockedTickets, setUnlockedTickets] = useState([]);
+ const [conversations, setConversations] = useState([]);
+
+ const fetchTickets = async () => {
+ const { data } = await apiFetchAllTicketsAsync();
+ setTickets(data);
+ };
+
+ useEffect(() => {
+ const fetchData = async () => {
+ const { data: ticketsData } = await apiFetchAllTicketsAsync();
+ const { data: conversationsData } = await apiFetchAllConversationsAsync();
+ setTickets(ticketsData);
+ setConversations(conversationsData);
+ };
+ fetchData();
+ }, []);
+
+ const selectedTicket = tickets.find((t) => t.id === selectedTicketId);
+ const selectedConversation = conversations.find(
+ (c) => c.id === selectedTicket?.conversationId
+ );
+
+ const handleUnlockChat = (ticketId) => {
+ setUnlockedTickets((prev) => [...new Set([...prev, ticketId])]);
+ };
+
+ return (
+
+
+
+
+ selectedTicketId && handleUnlockChat(selectedTicketId)
+ }
+ />
+
+
+ );
+}
diff --git a/src/pages/CreateUserPage.jsx b/src/pages/CreateUserPage.jsx
new file mode 100644
index 0000000..c82e781
--- /dev/null
+++ b/src/pages/CreateUserPage.jsx
@@ -0,0 +1,12 @@
+import React from 'react';
+import UserCreateSection from '../sections/UserCreateSection';
+
+const CreateUserPage = () => {
+ return (
+
+
+
+ );
+};
+
+export default CreateUserPage;
diff --git a/src/pages/DelRoutePage.jsx b/src/pages/DelRoutePage.jsx
new file mode 100644
index 0000000..8fa116c
--- /dev/null
+++ b/src/pages/DelRoutePage.jsx
@@ -0,0 +1,319 @@
+// src/pages/RoutesPage.jsx
+import React, { useState, useEffect, useCallback } from 'react';
+import RouteMap from '../components/RouteMap'; // Assuming RouteMap is in src/components/
+import RoutesHeader from '@sections/RoutesHeader';
+import CreateRouteModal from "../components/CreateRouteModal";
+// MUI Imports
+import {
+ Box,
+ Grid,
+ List,
+ ListItem,
+ ListItemButton,
+ ListItemText,
+ Typography,
+ CircularProgress,
+ Paper,
+ Alert,
+ Divider,
+ Container,
+} from '@mui/material';
+import MapIcon from '@mui/icons-material/Map'; // Example icon
+import DirectionsIcon from '@mui/icons-material/Directions';
+import ListAltIcon from '@mui/icons-material/ListAlt';
+import { apiGetRoutesAsync, apiCreateRouteAsync } from '../api/api';
+
+// You might want to wrap your App in a ThemeProvider in App.jsx or main.jsx
+// import { ThemeProvider, createTheme } from '@mui/material/styles';
+// const theme = createTheme(); -> Then wrap around your app
+
+function RoutesPage2() {
+ const [routeList, setRouteList] = useState([]);
+ const [selectedRouteId, setSelectedRouteId] = useState(null);
+ const [selectedRouteData, setSelectedRouteData] = useState(null);
+ const [isLoadingList, setIsLoadingList] = useState(false);
+ const [isLoadingDetails, setIsLoadingDetails] = useState(false);
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [error, setError] = useState(null);
+ useEffect(() => {
+ const fetchRouteList = async () => {
+ setIsLoadingList(true);
+ setError(null);
+ try {
+ //const response = await fetch('/api/routes'); // EXAMPLE: /api/routes
+ const response = await apiGetRoutesAsync();
+ // if (!response.ok) {
+ // throw new Error(`HTTP error! status: ${response.status}`);
+ // }
+ console.log(response.data);
+ setRouteList(response.data);
+ } catch (e) {
+ console.error('Failed to fetch route list:', e);
+ setError('Failed to load route list. ' + e.message);
+ setRouteList([]);
+ } finally {
+ setIsLoadingList(false);
+ }
+ };
+ fetchRouteList();
+ }, []);
+
+ useEffect(() => {
+ if (!selectedRouteId) {
+ setSelectedRouteData(null);
+ return;
+ }
+ const fetchRouteDetails = async () => {
+ setIsLoadingDetails(true);
+ setSelectedRouteData(null);
+ setError(null);
+ try {
+ // const response = await fetch(`/api/routes/${selectedRouteId}`); // EXAMPLE: /api/routes/12
+ // if (!response.ok) {
+ // throw new Error(`HTTP error! status: ${response.status}`);
+ // }
+ const data = routeList.find((r) => r.id == selectedRouteId);
+ console.log(data);
+ setSelectedRouteData(data);
+ } catch (e) {
+ console.error(
+ `Failed to fetch details for route ${selectedRouteId}:`,
+ e
+ );
+ setError(
+ `Failed to load details for route ${selectedRouteId}. ` + e.message
+ );
+ setSelectedRouteData(null);
+ } finally {
+ setIsLoadingDetails(false);
+ }
+ };
+ fetchRouteDetails();
+ }, [selectedRouteId]);
+
+ const handleRouteClick = useCallback((routeId) => {
+ setSelectedRouteId(routeId);
+ }, []);
+
+ const handleCreate = () => {
+ setIsCreateModalOpen(true);
+ };
+
+ const handleCreateRoute = async (orders) => {
+ try {
+
+ const rez = await apiCreateRouteAsync(orders);
+ const rute = await apiGetRoutesAsync();
+ setRouteList(rute);
+ console.log('Uradjeno');
+ setIsCreateModalOpen(false);
+ } catch (error) {
+ console.error('API error:', error);
+ }
+ };
+
+ return (
+
+
+ {' '}
+ {/* Overall page container */}
+
+ {/* Adjust height as needed */}
+ {/* Routes List Panel */}
+
+
+
+
+
+ Available Routes
+
+
+ {isLoadingList && (
+
+
+
+ )}
+ {error && !isLoadingList && (
+
+ {error}
+
+ )}
+ {!isLoadingList && routeList.length === 0 && !error && (
+
+ No routes available.
+
+ )}
+ {!isLoadingList && routeList.length > 0 && (
+
+ {routeList.map((route) => (
+ handleRouteClick(route.id)}
+ >
+
+
+ ))}
+
+ )}
+
+
+ {/* Route Map and Details Panel */}
+
+
+ {isLoadingDetails && (
+
+
+
+ Loading map for Route ID: {selectedRouteId}...
+
+
+ )}
+ {error && !isLoadingDetails && selectedRouteId && (
+ {error}
+ )}
+
+ {!selectedRouteId && !isLoadingDetails && !error && (
+
+
+
+ Select a route from the list to view it on the map.
+
+
+ )}
+
+ {selectedRouteData && !isLoadingDetails && !error && (
+ <>
+
+
+
+ Map for Route ID: {selectedRouteData.id}
+
+
+
+ {' '}
+ {/* Ensure map has space */}
+
+
+
+ {selectedRouteData.routeData?.data?.routes?.[0]?.legs?.[0]
+ ?.steps && (
+ <>
+
+
+
+
+ Directions:
+
+
+
+ {' '}
+ {/* Scrollable directions */}
+
+ {selectedRouteData.routeData.data.routes[0].legs[0].steps.map(
+ (step, index) => (
+
+
+ }
+ />
+
+ )
+ )}
+
+
+ >
+ )}
+ >
+ )}
+
+
+
+
+ setIsCreateModalOpen(false)}
+ onCreateRoute={handleCreateRoute}
+ />
+
+ );
+}
+
+export default RoutesPage2;
diff --git a/src/pages/LanguageManagementPage.jsx b/src/pages/LanguageManagementPage.jsx
new file mode 100644
index 0000000..5ab3b40
--- /dev/null
+++ b/src/pages/LanguageManagementPage.jsx
@@ -0,0 +1,251 @@
+import React, { useState, useEffect } from 'react';
+import {
+ Box,
+ Typography,
+ Card,
+ CardContent,
+ Button,
+ Grid,
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ TextField,
+ IconButton,
+ Alert,
+} from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import AddIcon from '@mui/icons-material/Add';
+import DeleteIcon from '@mui/icons-material/Delete';
+import EditIcon from '@mui/icons-material/Edit';
+
+const LanguageManagementPage = () => {
+ const { t, i18n } = useTranslation();
+ const [languages, setLanguages] = useState([]);
+ const [isAddModalOpen, setIsAddModalOpen] = useState(false);
+ const [newLanguage, setNewLanguage] = useState({
+ code: '',
+ name: '',
+ translations: {},
+ });
+ const [error, setError] = useState('');
+ const [translationsText, setTranslationsText] = useState('');
+ const [jsonError, setJsonError] = useState('');
+
+ useEffect(() => {
+ fetchLanguages();
+ }, []);
+
+ const fetchLanguages = async () => {
+ try {
+ const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/languages`);
+ const data = await response.json();
+ setLanguages(data);
+ const masterkeys = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/master-keys`);
+ const keydata = await masterkeys.json();
+ console.log(keydata);
+ let obj = "{\n";
+ for (let index = 0; index < keydata.length; index++) {
+ obj += `\t "${keydata[index]}": "",\n`
+ }
+ obj += "}"
+ setTranslationsText(obj);
+ } catch (error) {
+ console.error('Failed to fetch languages:', error);
+ setError('Failed to load languages');
+ }
+ };
+
+ const handleAddLanguage = () => {
+ setIsAddModalOpen(true);
+ };
+
+ const handleCloseModal = () => {
+ setIsAddModalOpen(false);
+ setNewLanguage({
+ code: '',
+ name: '',
+ translations: {},
+ });
+ setTranslationsText('');
+ setJsonError('');
+ setError('');
+ };
+
+ const handleSaveLanguage = async () => {
+ try {
+ if (!newLanguage.code || !newLanguage.name) {
+ setError('Language code and name are required');
+ return;
+ }
+
+ if (jsonError) {
+ setError('Fix translation JSON errors before saving.');
+ return;
+ }
+ console.log(JSON.stringify(newLanguage));
+ const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/languages`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(newLanguage),
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to add language');
+ }
+
+ await fetchLanguages();
+ handleCloseModal();
+ } catch (error) {
+ console.error('Error adding language:', error);
+ setError('Failed to add language');
+ }
+ };
+
+ const handleDeleteLanguage = async (code) => {
+ try {
+ const response = await fetch(`${import.meta.env.VITE_API_BASE_URL}/api/translations/languages/${code}`, {
+ method: 'DELETE',
+ });
+
+ if (!response.ok) {
+ throw new Error('Failed to delete language');
+ }
+
+ await fetchLanguages();
+ } catch (error) {
+ console.error('Error deleting language:', error);
+ setError('Failed to delete language');
+ }
+ };
+
+ const handleChangeLanguage = (code) => {
+ i18n.changeLanguage(code);
+ };
+
+ return (
+
+
+
+ {t('common.languageManagement')}
+
+
+
+
+
+ {t('common.currentLanguage')}
+
+
+ {
+ languages.find(lang => lang.code === i18n.language)?.name
+ ? `${languages.find(lang => lang.code === i18n.language).name} (${i18n.language})`
+ : `English (${i18n.language})`
+ }
+
+
+
+
+
+
+ {t('common.availableLanguages')}
+ } onClick={handleAddLanguage}>
+ {t('common.addLanguage')}
+
+
+
+
+ {languages.map((language) => (
+
+
+
+
+
+ {language.name} ({language.code})
+
+
+ handleChangeLanguage(language.code)}
+ disabled={language.code === i18n.language}
+ >
+
+
+ handleDeleteLanguage(language.code)}
+ disabled={language.code === 'en'}
+ >
+
+
+
+
+
+
+
+ ))}
+
+
+
+
+
+
+ );
+};
+
+export default LanguageManagementPage;
diff --git a/src/pages/LoginPage.jsx b/src/pages/LoginPage.jsx
new file mode 100644
index 0000000..a8d6f53
--- /dev/null
+++ b/src/pages/LoginPage.jsx
@@ -0,0 +1,84 @@
+import React, { useEffect } from "react";
+import { Box } from "@mui/material";
+import LoginFormSection from "../sections/LoginFormSection";
+import backgroundImg from "@images/Bazaar.png";
+
+const LoginPage = () => {
+ useEffect(() => {
+ document.body.classList.add("login-background");
+ return () => {
+ document.body.classList.remove("login-background");
+ };
+ }, []);
+
+ return (
+
+
+ {/* Lijevi box sa slikom */}
+
+
+ {/* Desni box sa formom */}
+
+
+
+
+
+ );
+};
+
+export default LoginPage;
diff --git a/src/pages/OrdersPage.jsx b/src/pages/OrdersPage.jsx
new file mode 100644
index 0000000..154037d
--- /dev/null
+++ b/src/pages/OrdersPage.jsx
@@ -0,0 +1,310 @@
+import React, { useState, useMemo, useEffect } from 'react';
+import { Box } from '@mui/material';
+import Sidebar from '@components/Sidebar';
+import OrdersTable from '../components/OrdersTable';
+import OrderDetailsPopup from '../components/OrderComponent';
+import OrdersHeader from '@sections/OrdersHeader';
+import UserManagementPagination from '@components/UserManagementPagination';
+import {
+ apiFetchOrdersAsync,
+ apiFetchApprovedUsersAsync,
+ apiGetAllStoresAsync,
+ apiDeleteOrderAsync,
+ apiGetProductCategoriesAsync,
+ apiGetStoreProductsAsync,
+ apiFetchDeliveryAddressByIdAsync,
+ apiGetStoreByIdAsync,
+} from '@api/api';
+
+const OrdersPage = () => {
+ const [tabValue, setTabValue] = useState('all');
+ const [selectedOrder, setSelectedOrder] = useState(null);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [sortField, setSortField] = useState('id');
+ const [sortOrder, setSortOrder] = useState('asc');
+ const [statusFilter, setStatusFilter] = useState('');
+ const [currentPage, setCurrentPage] = useState(1);
+ const [orders, setOrders] = useState([]);
+ const ordersPerPage = 10;
+
+ useEffect(() => {
+ const fetchData = async () => {
+ try {
+ // 1. Dohvati osnovne podatke (paralelno)
+ const [ordersData, users, stores, categories] = await Promise.all([
+ apiFetchOrdersAsync(), // Vraća ordersData, koji treba da sadrži addressId i storeId
+ apiFetchApprovedUsersAsync(), // Vraća listu korisnika
+ apiGetAllStoresAsync(), // Vraća listu svih prodavnica (možda bez adrese detalja)
+ apiGetProductCategoriesAsync(), // Vraća kategorije proizvoda
+ ]);
+
+ const usersMap = Object.fromEntries(
+ users.map((u) => [u.id, u.userName || u.email])
+ );
+ const storesMap = Object.fromEntries(stores.map((s) => [s.id, s.name]));
+ const categoryMap = Object.fromEntries(
+ categories.map((c) => [c.id, c.name])
+ );
+
+ const productsMap = {};
+ const allProducts = [];
+
+ for (const store of stores) {
+ try {
+ const res = await apiGetStoreProductsAsync(store.id);
+ if (res.status === 200 && res.data) {
+ allProducts.push(...res.data);
+ }
+ } catch (error) {
+ console.error(
+ `Failed to fetch products for store ID ${store.id}:`,
+ error
+ );
+ }
+ }
+ allProducts.forEach((p) => (productsMap[p.id] = p));
+
+ const uniqueStoreIds = [
+ ...new Set(
+ ordersData
+ .map((order) => order.storeId)
+ .filter((id) => id !== undefined && id !== null)
+ ),
+ ];
+ const uniqueAddressIds = [
+ ...new Set(
+ ordersData
+ .map((order) => order.addressId)
+ .filter((id) => id !== undefined && id !== null)
+ ),
+ ];
+
+ const storeDetailsPromises = uniqueStoreIds.map((storeId) =>
+ apiGetStoreByIdAsync(storeId).catch((err) => {
+ console.error(
+ `Failed to fetch store details for ID ${storeId}:`,
+ err
+ );
+ return { data: { address: 'N/A', id: storeId } };
+ })
+ );
+
+ const deliveryAddressPromises = uniqueAddressIds.map((addressId) =>
+ apiFetchDeliveryAddressByIdAsync(addressId).catch((err) => {
+ console.error(
+ `Failed to fetch delivery address for ID ${addressId}:`,
+ err
+ );
+ return { address: 'N/A', id: addressId };
+ })
+ );
+
+ const [storeDetailsResponses, deliveryAddressResponses] =
+ await Promise.all([
+ Promise.all(storeDetailsPromises),
+ Promise.all(deliveryAddressPromises),
+ ]);
+
+ const storeDetailsMap = Object.fromEntries(
+ storeDetailsResponses.map((res) => [
+ res.data?.id || res.id,
+ res.data || res,
+ ])
+ );
+ const deliveryAddressesMap = Object.fromEntries(
+ deliveryAddressResponses.map((res) => [
+ res.data?.id || res.id,
+ res.data || res,
+ ])
+ );
+
+ console.log('USERS', users);
+ console.log('ORDERSData: ', ordersData);
+ console.log(
+ 'ORDERS with Address ID:',
+ ordersData.map((o) => ({ id: o.id, addressId: o.addressId }))
+ );
+
+ const enrichedOrders = ordersData.map((order) => {
+ const storeDetails = storeDetailsMap[order.storeId];
+ const deliveryAddressDetails = deliveryAddressesMap[order.addressId];
+
+ const storeName = storesMap[parseInt(order.storeId)] ?? order.storeId; // Koristi storeName iz svih stores lookup-a
+ const storeAddress = storeDetails?.address ?? 'N/A'; // Koristi 'address' iz detalja prodavnice
+
+ const deliveryAddress = deliveryAddressDetails?.address ?? 'N/A'; // Koristi 'address' iz detalja adrese dostave
+
+ const enrichedOrderItems = (order.orderItems ?? []).map((item) => {
+ const prod = productsMap[item.productId] ?? {};
+ const productCategory =
+ categoryMap[prod.productCategoryId] ?? 'Unknown Category';
+
+ return {
+ ...item,
+ name: prod.name ?? `Product ${item.productId}`,
+ imageUrl: prod.photos?.[0]?.relativePath
+ ? `${import.meta.env.VITE_API_BASE_URL}${prod.photos[0].relativePath}`
+ : 'https://via.placeholder.com/80',
+ tagIcon: '🏷️',
+ tagLabel: productCategory,
+ };
+ });
+
+ return {
+ ...order,
+ buyerName: usersMap[order.buyerId] ?? order.buyerId,
+ storeName: storeName,
+ storeAddress: storeAddress,
+ deliveryAddress: deliveryAddress,
+ _productDetails: enrichedOrderItems,
+ // ...ostali property-ji koje već imaš (status, time, total...)
+ };
+ });
+
+ // 6. Postavi obogaćene narudžbe u state
+ setOrders(enrichedOrders);
+ } catch (error) {
+ console.error('Failed to fetch initial data for OrdersPage:', error);
+ }
+ };
+
+ fetchData();
+ }, []);
+
+ const handleDeleteOrder = async (orderId) => {
+ const res = await apiDeleteOrderAsync(orderId);
+ if (res.status === 204) {
+ setOrders((prev) => prev.filter((o) => o.id !== orderId));
+ } else {
+ alert('Failed to delete order.');
+ }
+ };
+
+ const filteredOrders = useMemo(() => {
+ const filteredByTab =
+ tabValue === 'all'
+ ? orders
+ : orders.filter((order) =>
+ tabValue === 'cancelled' ? order.isCancelled : !order.isCancelled
+ );
+
+ return filteredByTab
+ .filter((order) =>
+ [order.buyerName, order.storeName].some((field) =>
+ field?.toLowerCase().includes(searchTerm.toLowerCase())
+ )
+ )
+ .filter((order) =>
+ statusFilter ? order.status?.toLowerCase() === statusFilter : true
+ );
+ }, [orders, tabValue, searchTerm, statusFilter]);
+
+ const sortedOrders = useMemo(() => {
+ return [...filteredOrders].sort((a, b) => {
+ if (sortField === 'createdAt') {
+ return sortOrder === 'asc'
+ ? new Date(a.createdAt) - new Date(b.createdAt)
+ : new Date(b.createdAt) - new Date(a.createdAt);
+ }
+ if (sortField === 'id') {
+ return sortOrder === 'asc' ? a.id - b.id : b.id - a.id;
+ }
+ return 0;
+ });
+ }, [filteredOrders, sortField, sortOrder]);
+
+ const totalPages = Math.max(
+ 1,
+ Math.ceil(sortedOrders.length / ordersPerPage)
+ );
+ const indexOfLastOrder = currentPage * ordersPerPage;
+ const indexOfFirstOrder = indexOfLastOrder - ordersPerPage;
+ const currentOrders = sortedOrders.slice(indexOfFirstOrder, indexOfLastOrder);
+
+ const handlePageChange = (newPage) => {
+ if (newPage >= 1 && newPage <= totalPages) {
+ setCurrentPage(newPage);
+ }
+ };
+
+ useEffect(() => {
+ setCurrentPage(1);
+ }, [searchTerm, tabValue, statusFilter]);
+
+ return (
+
+
+
+
+
+
+ {
+ setSortField(field);
+ setSortOrder(order);
+ }}
+ onOrderClick={(order) => setSelectedOrder(order)}
+ onDelete={handleDeleteOrder}
+ />
+
+
+
+
+ {selectedOrder && (
+ setSelectedOrder(null)}
+ narudzba={{
+ id: selectedOrder.id,
+ buyerId: selectedOrder.buyerName,
+ storeId: selectedOrder.storeName,
+ status: selectedOrder.status,
+ time: selectedOrder.createdAt,
+ total: selectedOrder.totalPrice,
+ proizvodi: selectedOrder._productDetails,
+ deliveryAddress: selectedOrder.deliveryAddress,
+ storeAddress: selectedOrder.storeAddress,
+ orderItems: selectedOrder.products.map((p) => ({
+ id: p.id,
+ productId: p.productId,
+ price: p.price,
+ quantity: p.quantity,
+ name: p.name,
+ })),
+ }}
+ />
+ )}
+
+
+ );
+};
+
+export default OrdersPage;
diff --git a/src/pages/PendingUsersPage.jsx b/src/pages/PendingUsersPage.jsx
new file mode 100644
index 0000000..932909d
--- /dev/null
+++ b/src/pages/PendingUsersPage.jsx
@@ -0,0 +1,143 @@
+import React, { useContext, useState } from "react";
+import PendingUsersHeader from "@sections/PendingUsersHeader";
+import PendingUsersTable from "@components/PendingUsersTable";
+import UserManagementPagination from "@components/UserManagementPagination";
+import ConfirmDialog from "@components/ConfirmDialog";
+import UserDetailsModal from "@components/UserDetailsModal";
+import { Box } from "@mui/material";
+import { PendingUsersContext } from "@context/PendingUsersContext";
+import { apiApproveUserAsync } from "@api/api";
+import { apiDeleteUserAsync } from "@api/api";
+
+
+var baseURL = import.meta.env.VITE_API_BASE_URL
+
+const PendingUsers = () => {
+ const usersPerPage = 8;
+
+ const { pendingUsers, setPendingUsers ,deleteUser} = useContext(PendingUsersContext);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [confirmOpen, setConfirmOpen] = useState(false);
+ const [userToDelete, setUserToDelete] = useState(null);
+
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [modalOpen, setModalOpen] = useState(false);
+
+ const filteredUsers = pendingUsers.filter(
+ (u) =>
+ u.userName.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ u.email.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ const totalPages = Math.max(
+ 1,
+ Math.ceil(filteredUsers.length / usersPerPage)
+ );
+ const indexOfLastUser = currentPage * usersPerPage;
+ const indexOfFirstUser = indexOfLastUser - usersPerPage;
+ const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
+
+ const handleApprove = async (id) => {
+ try {
+ deleteUser(id);
+ await apiApproveUserAsync(id);
+ console.log(`User with ID ${id} approved successfully.`);
+ } catch (error) {
+ console.error("Greška pri odobravanju korisnika:", error);
+ }
+ };
+
+ const handleDelete = (id) => {
+ setUserToDelete(id);
+ setConfirmOpen(true);
+ };
+
+ const confirmDelete = async () => {
+ setConfirmOpen(false);
+ setUserToDelete(null);
+ try {
+ await apiDeleteUserAsync(userToDelete);
+ deleteUser(userToDelete);
+ console.log(`User with ID ${userToDelete} deleted successfully.`);
+ } catch (error) {
+ console.error("Greška pri brisanju korisnika:", error);
+ }
+ };
+
+ const cancelDelete = () => {
+ setConfirmOpen(false);
+ setUserToDelete(null);
+ };
+
+ const handlePageChange = (newPage) => {
+ if (newPage >= 1 && newPage <= totalPages) {
+ setCurrentPage(newPage);
+ }
+ };
+
+ const handleViewUser = (userId) => {
+ const user = pendingUsers.find((u) => u.id === userId);
+ setSelectedUser(user);
+ setModalOpen(true);
+ };
+
+ return (
+
+
+ {}}
+ searchTerm={searchTerm}
+ setSearchTerm={setSearchTerm}
+ />
+
+
+
+
+
+
+
+ setModalOpen(false)}
+ user={selectedUser}
+ readOnly
+ />
+
+
+ );
+};
+
+export default PendingUsers;
diff --git a/src/pages/RoutesPage.jsx b/src/pages/RoutesPage.jsx
new file mode 100644
index 0000000..c0abf57
--- /dev/null
+++ b/src/pages/RoutesPage.jsx
@@ -0,0 +1,206 @@
+import React, { useState, useEffect } from 'react';
+import { Box, Typography, Grid } from '@mui/material';
+import RouteCard from '@components/RouteCard';
+import UserManagementPagination from '@components/UserManagementPagination';
+import RoutesHeader from '@sections/RoutesHeader';
+import RouteDetailsModal from '@components/RouteDetailsModal';
+import { sha256 } from 'js-sha256';
+import CreateRouteModal from '@components/CreateRouteModal';
+import {
+ apiCreateRouteAsync,
+ apiGetRoutesAsync,
+ apiDeleteRouteAsync,
+} from '../api/api';
+import {
+ GoogleMap,
+ LoadScript,
+ Polyline,
+ Marker,
+ InfoWindow,
+} from '@react-google-maps/api';
+
+const API_KEY = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
+
+const getBoundingBox = (points) => {
+ if (!points || points.length === 0) return null;
+
+ // THIS LINE NEEDS `window.google` to be defined
+ const bounds = new google.maps.LatLngBounds();
+ points.forEach((point) => {
+ // THIS LINE ALSO NEEDS `window.google`
+ bounds.extend(new google.maps.LatLng(point.latitude, point.longitude));
+ });
+ return bounds;
+};
+
+const generateMockRoutes = (page, perPage) => {
+ const totalRoutes = 42;
+ const routes = Array.from({ length: totalRoutes }, (_, i) => {
+ const numOrders = Math.floor(Math.random() * 6) + 1;
+ const orderIds = Array.from({ length: numOrders }, () =>
+ Math.floor(1000 + Math.random() * 9000)
+ );
+
+ const mockData = {
+ routes: [
+ {
+ legs: [
+ {
+ start_location: { lat: 43.85 + 0.01 * i, lng: 18.38 + 0.01 * i },
+ end_location: { lat: 43.86 + 0.01 * i, lng: 18.4 + 0.01 * i },
+ },
+ ],
+ },
+ ],
+ };
+
+ const hash = sha256(JSON.stringify(mockData));
+
+ return {
+ id: i + 1,
+ name: `Route ${i + 1}`,
+ orderIds,
+ ownerId: 1,
+ routeData: {
+ data: mockData,
+ hash,
+ routeId: `route-${i + 1}`,
+ },
+ };
+ });
+
+ const start = (page - 1) * perPage;
+ const end = start + perPage;
+ return {
+ data: routes.slice(start, end),
+ total: totalRoutes,
+ };
+};
+
+const RoutesPage = () => {
+ const [selectedRoute, setSelectedRoute] = useState(null);
+ const [isModalOpen, setIsModalOpen] = useState(false);
+ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false);
+ const [routes, setRoutes] = useState([]);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [totalPages, setTotalPages] = useState(1);
+ const perPage = 8;
+
+ useEffect(() => {
+ const fetchRoutes = async () => {
+ //const response = generateMockRoutes(currentPage, perPage);
+ const response = await apiGetRoutesAsync();
+ console.log(response);
+const totalItems = response.length;
+setTotalPages(Math.ceil(totalItems / perPage));
+
+ // Clamp currentPage to stay within valid bounds
+ const safePage = Math.max(0, Math.min(currentPage - 1, totalPages - 1));
+
+ const start = safePage * perPage;
+ const end = start + perPage;
+
+ setRoutes(response.slice(start, end));
+ };
+ fetchRoutes();
+ }, [currentPage,perPage]);
+
+
+ const handleCreate = () => {
+ setIsCreateModalOpen(true);
+ };
+
+ const handleCreateRoute = async (orders) => {
+ try {
+
+ const rez = await apiCreateRouteAsync(orders);
+ const rute = await apiGetRoutesAsync();
+ setRoutes(rute);
+ console.log('Uradjeno');
+ setIsCreateModalOpen(false);
+ } catch (error) {
+ console.error('API error:', error);
+ }
+ };
+ const handleDelete = async (id) => {
+ try {
+ const rez = await apiDeleteRouteAsync(id);
+ const newroutes = await apiGetRoutesAsync();
+ setRoutes(newroutes);
+ } catch (err) {
+ console.log('Greska pri brisanju', err);
+ }
+ };
+
+ const handleViewDetails = (id) => {
+ const selected = routes.find((r) => r.id === id);
+ console.log('Selected route:', selected);
+ setSelectedRoute(selected);
+ setIsModalOpen(true);
+ };
+
+ return (
+
+
+
+
+
+ {routes.map((route) => (
+
+
+
+ ))}
+
+
+
+
+ setIsModalOpen(false)}
+ />
+
+
+ setIsCreateModalOpen(false)}
+ onCreateRoute={handleCreateRoute}
+ />
+
+ );
+};
+
+export default RoutesPage;
diff --git a/src/pages/SellerAnalyticsPage.jsx b/src/pages/SellerAnalyticsPage.jsx
new file mode 100644
index 0000000..c453501
--- /dev/null
+++ b/src/pages/SellerAnalyticsPage.jsx
@@ -0,0 +1,502 @@
+import React, { useEffect, useState } from 'react';
+import { Box, Typography, Grid, Card, CardContent } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+import {
+ LineChart,
+ Line,
+ XAxis,
+ YAxis,
+ CartesianGrid,
+ Tooltip,
+ ResponsiveContainer,
+ Legend,
+} from 'recharts';
+import { DollarSign, TrendingUp, BarChart2, Store } from 'lucide-react';
+
+// mock data (will be used if props don't provide enough data for calculation)
+const mockStats = [
+ {
+ month: 'Jan',
+ earningsFromClicks: 80,
+ earningsFromViews: 20,
+ earningsFromConversions: 100000,
+ },
+ {
+ month: 'Feb',
+ earningsFromClicks: 90,
+ earningsFromViews: 25,
+ earningsFromConversions: 105,
+ },
+ {
+ month: 'Mar',
+ earningsFromClicks: 110,
+ earningsFromViews: 30,
+ earningsFromConversions: 120,
+ },
+ {
+ month: 'Apr',
+ earningsFromClicks: 130,
+ earningsFromViews: 40,
+ earningsFromConversions: 150,
+ },
+ {
+ month: 'May',
+ earningsFromClicks: 145,
+ earningsFromViews: 50,
+ earningsFromConversions: 160,
+ },
+ {
+ month: 'Jun',
+ earningsFromClicks: 160,
+ earningsFromViews: 60,
+ earningsFromConversions: 175,
+ },
+];
+const mockRealtimeStats = {
+ sellerName: 'N/A (Using Mock)',
+ earningsFromClicks: 106.0,
+ earningsFromClicksOverTime: [10, 20, 30, 46, 55, 63],
+ earningsFromViews: 33.6,
+ earningsFromViewsOverTime: [5, 8, 10, 10.6, 12, 14],
+ earningsFromConversions: 220.0,
+ earningsFromConversionsOverTime: [40, 60, 50, 70, 85, 100000],
+ totalEarnings: 359.6,
+ sellerProfit: 287.68,
+};
+
+const iconMap = {
+ 'Total Earnings': ,
+ 'Seller Profit': ,
+ 'View Revenue': , // Note: Duplicate key with 'Conversion Revenue', consider unique keys if icons differ
+ 'Conversion Revenue': ,
+ // Added for Click Revenue to avoid undefined icon
+ 'Click Revenue': ,
+};
+
+const storeToStats = (store, ads, clickData, viewData, conversionData) => {
+ if (!store || !ads || !clickData || !viewData || !conversionData)
+ return mockStats; // Fallback
+ const monthlyAggregatedStats = {};
+
+ const getMonthStats = (monthKey) => {
+ if (!monthlyAggregatedStats[monthKey]) {
+ monthlyAggregatedStats[monthKey] = {
+ earningsFromClicks: 0,
+ earningsFromViews: 0,
+ earningsFromConversions: 0,
+ };
+ }
+ return monthlyAggregatedStats[monthKey];
+ };
+
+ clickData.forEach((entry) => {
+ const ad = ads.find((a) => a.id === entry.id);
+ if (!ad || typeof ad.clickPrice !== 'number') return;
+ (entry.clicks || []).forEach((clickTimestamp) => {
+ const timestamp = new Date(clickTimestamp);
+ const start = new Date(ad.startTime);
+ const end = new Date(ad.endTime);
+ if (timestamp >= start && timestamp <= end) {
+ const monthKey = `${timestamp.getUTCFullYear()}-${String(timestamp.getUTCMonth() + 1).padStart(2, '0')}`;
+ const stats = getMonthStats(monthKey);
+ stats.earningsFromClicks += ad.clickPrice;
+ }
+ });
+ });
+
+ viewData.forEach((entry) => {
+ const ad = ads.find((a) => a.id === entry.id);
+ if (!ad || typeof ad.viewPrice !== 'number') return;
+ (entry.views || []).forEach((viewTimestamp) => {
+ const timestamp = new Date(viewTimestamp);
+ const start = new Date(ad.startTime);
+ const end = new Date(ad.endTime);
+ if (timestamp >= start && timestamp <= end) {
+ const monthKey = `${timestamp.getUTCFullYear()}-${String(timestamp.getUTCMonth() + 1).padStart(2, '0')}`;
+ const stats = getMonthStats(monthKey);
+ stats.earningsFromViews += ad.viewPrice;
+ }
+ });
+ });
+
+ conversionData.forEach((entry) => {
+ const ad = ads.find((a) => a.id === entry.id);
+ if (!ad || typeof ad.conversionPrice !== 'number') return;
+ (entry.conversions || []).forEach((conversionTimestamp) => {
+ const timestamp = new Date(conversionTimestamp);
+ const start = new Date(ad.startTime);
+ const end = new Date(ad.endTime);
+ if (timestamp >= start && timestamp <= end) {
+ const monthKey = `${timestamp.getUTCFullYear()}-${String(timestamp.getUTCMonth() + 1).padStart(2, '0')}`;
+ const stats = getMonthStats(monthKey);
+ stats.earningsFromConversions += ad.conversionPrice;
+ }
+ });
+ });
+
+ const result = [];
+ const monthNames = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+ ];
+ const sortedMonthKeys = Object.keys(monthlyAggregatedStats).sort();
+
+ if (sortedMonthKeys.length === 0) return mockStats; // Fallback if no data processed
+
+ sortedMonthKeys.forEach((monthKey) => {
+ const [year, monthNumStr] = monthKey.split('-');
+ const monthIndex = parseInt(monthNumStr, 10) - 1;
+ const monthName = monthNames[monthIndex];
+ result.push({
+ month: monthName,
+ earningsFromClicks: parseFloat(
+ monthlyAggregatedStats[monthKey].earningsFromClicks.toFixed(2)
+ ),
+ earningsFromViews: parseFloat(
+ monthlyAggregatedStats[monthKey].earningsFromViews.toFixed(2)
+ ),
+ earningsFromConversions: parseFloat(
+ monthlyAggregatedStats[monthKey].earningsFromConversions.toFixed(2)
+ ),
+ });
+ });
+ return result; // Ensure we don't return empty if processing happened but yielded no months
+};
+
+const storeToSummary = (store, ads, products, clicks, views, conversions) => {
+ if (!store || !ads || !products || !clicks || !views || !conversions)
+ return {
+ sellerName: 'N/A (Using default)',
+ earningsFromClicks: 0.0,
+ earningsFromClicksOverTime: [0],
+ earningsFromViews: 0,
+ earningsFromViewsOverTime: [0],
+ earningsFromConversions: 0,
+ earningsFromConversionsOverTime: [0],
+ totalEarnings: 0,
+ sellerProfit: 0,
+ }; // Fallback
+
+ // Calculate revenues based on ad properties (assuming these are aggregated counts on ad objects)
+ const clickrev = ads.reduce(
+ (acc, ad) => acc + (ad.clicks || 0) * (ad.clickPrice || 0),
+ 0
+ );
+ const convrev = ads.reduce(
+ (acc, ad) => acc + (ad.conversions || 0) * (ad.conversionPrice || 0),
+ 0
+ );
+ const viewrev = ads.reduce(
+ (acc, ad) => acc + (ad.views || 0) * (ad.viewPrice || 0),
+ 0
+ );
+
+ const sellerrev = ads.reduce((acc, ad) => {
+ if (!ad.adData || !ad.adData[0] || !ad.adData[0].productId) return acc;
+ const product = products.find((p) => p.id === ad.adData[0].productId);
+ return acc + (ad.conversions || 0) * (product ? product.retailPrice : 0);
+ }, 0);
+
+ // Process detailed click/view/conversion data for "OverTime" arrays
+ // These expect `clicks`, `views`, `conversions` to be arrays of { id: adId, clicks/views/conversions: [timestamps] }
+ // The .fill() part is tricky; it replaces timestamps with prices. If the goal is a list of earnings per event:
+ const c_processed = clicks
+ .map((adDetail) => {
+ const adConfig = ads.find((ad) => ad.id === adDetail.id);
+ if (!adConfig) return [];
+ return (adDetail.clicks || []).map(() => adConfig.clickPrice); // Array of prices, one for each click
+ })
+ .flat();
+
+ const v_processed = views
+ .map((adDetail) => {
+ const adConfig = ads.find((ad) => ad.id === adDetail.id);
+ if (!adConfig) return [];
+ return (adDetail.views || []).map(() => adConfig.viewPrice);
+ })
+ .flat();
+
+ const cc_processed = conversions
+ .map((adDetail) => {
+ const adConfig = ads.find((ad) => ad.id === adDetail.id);
+ if (!adConfig) return [];
+ return (adDetail.conversions || []).map(() => adConfig.conversionPrice);
+ })
+ .flat();
+
+ const totalEarnings = convrev + viewrev + clickrev;
+ const sellerProfit = sellerrev - totalEarnings;
+
+ return {
+ sellerName: store.name || 'Unknown Store',
+ earningsFromClicks: parseFloat(clickrev.toFixed(2)),
+ earningsFromClicksOverTime: c_processed.length > 0 ? c_processed : [0],
+
+ earningsFromViews: parseFloat(viewrev.toFixed(2)),
+ earningsFromViewsOverTime: v_processed.length > 0 ? v_processed : [0],
+ earningsFromConversions: parseFloat(convrev.toFixed(2)),
+ earningsFromConversionsOverTime:
+ cc_processed.length > 0 ? cc_processed : [0],
+ totalEarnings: parseFloat(totalEarnings.toFixed(2)),
+ sellerProfit: parseFloat(sellerProfit.toFixed(2)),
+ };
+};
+
+const SellerAnalytics = ({
+ store,
+ ads,
+ products,
+ allClicks, // Expected: [{ id: adId, clicks: [timestamp1, ...] }, ...]
+ allViews, // Expected: [{ id: adId, views: [timestamp1, ...] }, ...]
+ allConversions, // Expected: [{ id: adId, conversions: [timestamp1, ...] }, ...]
+}) => {
+ const { t } = useTranslation();
+ const [stats, setStats] = useState(mockStats);
+ const [summary, setSummary] = useState(mockRealtimeStats);
+
+ useEffect(() => {
+ // console.log("SellerAnalytics useEffect triggered. Store:", store);
+ // console.log("Ads for store:", ads);
+ // console.log("Products for store:", products);
+ // console.log("AllClicks for store:", allClicks);
+
+ if (store && ads && products && allClicks && allViews && allConversions) {
+ const calculatedStats = storeToStats(
+ store,
+ ads,
+ allClicks,
+ allViews,
+ allConversions
+ );
+ const calculatedSummary = storeToSummary(
+ store,
+ ads,
+ products,
+ allClicks,
+ allViews,
+ allConversions
+ );
+
+ // console.log("Calculated Stats:", calculatedStats);
+ // console.log("Calculated Summary:", calculatedSummary);
+
+ setStats(calculatedStats);
+ setSummary(calculatedSummary);
+ console.log(store);
+ } else {
+ console.log('SellerAnalytics: Missing some props, using mock data.');
+ // Fallback to ensure summary always has a sellerName if store is somehow undefined briefly
+ setSummary((prev) => ({
+ ...mockRealtimeStats,
+ sellerName: store?.name || mockRealtimeStats.sellerName,
+ }));
+ setStats(mockStats);
+ }
+ }, [store, ads, products, allClicks, allViews, allConversions]);
+
+ const topStats = [
+ {
+ label: t('sellerAnalytics.totalEarnings'),
+ value: `${summary.totalEarnings.toFixed(2)} €`,
+ change: -5.2,
+ },
+ {
+ label: t('sellerAnalytics.sellerProfit'),
+ value: `${summary.sellerProfit.toFixed(2)} €`,
+ change: 2.1,
+ },
+ {
+ label: t('sellerAnalytics.clickRevenue'),
+ value: `${summary.earningsFromClicks.toFixed(2)} €`,
+ change: 1.5,
+ },
+ {
+ label: t('sellerAnalytics.viewRevenue'),
+ value: `${summary.earningsFromViews.toFixed(2)} €`,
+ change: -3.6,
+ },
+ {
+ label: t('sellerAnalytics.conversionRevenue'),
+ value: `${summary.earningsFromConversions.toFixed(2)} €`,
+ change: 4.9,
+ },
+ ];
+
+ return (
+
+
+ {t('analytics.storePerformance')}: {summary.sellerName}
+
+
+
+
+ {t('analytics.detailedAnalyticsFor', { storeName: summary.sellerName })}
+
+
+
+
+ {' '}
+ {/* Adjusted spacing */}
+ {topStats.map((item, idx) => (
+
+ {' '}
+ {/* More responsive grid items */}
+
+
+
+
+ {item.label}
+
+
+ {iconMap[item.label] || }
+
+
+
+ {' '}
+ {/* Adjusted font size */}
+ {item.value}
+
+ {item.change !== undefined && (
+
+ {/* {item.change < 0 ? '↓' : '↑'}{' '}
+ {Math.abs(item.change).toFixed(1)}% vs last month */}
+
+ )}
+
+
+
+ ))}
+
+
+
+ {' '}
+ {/* Adjusted spacing */}
+ {[
+ {
+ title: t('analytics.clickRevenueOverTime'),
+ color: '#0f766e',
+ data: summary.earningsFromClicksOverTime,
+ },
+ {
+ title: t('analytics.viewRevenueOverTime'),
+ color: '#f59e0b',
+ data: summary.earningsFromViewsOverTime,
+ },
+ {
+ title: t('analytics.conversionRevenueOverTime'),
+ color: '#ef4444',
+ data: summary.earningsFromConversionsOverTime,
+ },
+ ].map((graph, idx) => (
+
+ {' '}
+ {/* Responsive charts */}
+
+
+ {' '}
+ {/* Adjusted font size */}
+ {graph.title}
+
+
+ {' '}
+ {/* Adjusted height */}
+ ({
+ // Ensure stats is not null and handle slice if fewer than 5 data points
+ month: s.month,
+ // Ensure graph.data is an array and access it safely
+ value:
+ Array.isArray(graph.data) && graph.data.length > i
+ ? graph.data.slice(-Math.min(5, graph.data.length))[i]
+ : 0,
+ }))}
+ >
+
+
+
+
+
+
+
+
+
+
+ ))}
+
+
+ );
+};
+
+export default SellerAnalytics;
diff --git a/src/pages/StoresPage.jsx b/src/pages/StoresPage.jsx
new file mode 100644
index 0000000..a0ac162
--- /dev/null
+++ b/src/pages/StoresPage.jsx
@@ -0,0 +1,102 @@
+import React, { useState, useEffect } from 'react';
+import { Box } from '@mui/material';
+import StoresHeader from '@sections/StoresHeader';
+import StoreCard from '@components/StoreCard';
+import UserManagementPagination from '@components/UserManagementPagination';
+import { apiGetAllStoresAsync, apiAddStoreAsync } from '@api/api';
+import AddStoreModal from '@components/AddStoreModal';
+
+const StoresPage = () => {
+ const [currentPage, setCurrentPage] = useState(1);
+ const [searchTerm, setSearchTerm] = useState('');
+ const [openModal, setOpenModal] = useState(false);
+ const storesPerPage = 8;
+
+ const [allStores, setAllStores] = useState([]);
+
+ useEffect(() => {
+ const fetchStores = async () => {
+ const data = await apiGetAllStoresAsync();
+ const mapped = data.map((store) => ({
+ ...store,
+ categoryId: store.categoryId || store.category?.id || 0,
+ }));
+ setAllStores(mapped);
+ };
+
+ fetchStores();
+ }, []);
+
+ const handleAddStore = async (newStoreData) => {
+ console.log('data', newStoreData);
+ const response = await apiAddStoreAsync(newStoreData);
+ console.log(response);
+ if (response.status < 400) {
+ const data = await apiGetAllStoresAsync();
+ setAllStores(data);
+ }
+ };
+
+ const filteredStores = allStores.filter(
+ (store) =>
+ store.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ store.description.toLowerCase().includes(searchTerm.toLowerCase())
+ );
+
+ const totalPages = Math.ceil(filteredStores.length / storesPerPage);
+ const indexOfLastStore = currentPage * storesPerPage;
+ const indexOfFirstStore = indexOfLastStore - storesPerPage;
+ const currentStores = filteredStores.slice(
+ indexOfFirstStore,
+ indexOfLastStore
+ );
+
+ return (
+
+
+ setOpenModal(true)}
+ />
+
+ {/* Grid layout */}
+
+ {currentStores.map((store) => (
+
+ ))}
+
+
+
+
+
+
+ setOpenModal(false)}
+ onAddStore={handleAddStore}
+ />
+
+ );
+};
+
+export default StoresPage;
diff --git a/src/pages/UsersManagement.jsx b/src/pages/UsersManagement.jsx
new file mode 100644
index 0000000..4052526
--- /dev/null
+++ b/src/pages/UsersManagement.jsx
@@ -0,0 +1,167 @@
+import React, { useState, useEffect } from "react";
+import UserManagementHeader from "@sections/UserManagementHeader";
+import UserManagementPagination from "@components/UserManagementPagination";
+import UserManagementSection from "@sections/UserManagementSection";
+import UserDetailsModal from "@components/UserDetailsModal";
+import { Box } from "@mui/material";
+import AddUserModal from "@components/AddUserModal";
+
+import {
+ apiFetchApprovedUsersAsync,
+ apiDeleteUserAsync,
+ apiCreateUserAsync,
+} from "../api/api.js";
+
+const UsersManagements = () => {
+ const usersPerPage = 8;
+ const [allUsers, setAllUsers] = useState([]);
+ const [currentPage, setCurrentPage] = useState(1);
+ const [isLoading, setIsLoading] = useState(true);
+ const [searchTerm, setSearchTerm] = useState("");
+ const [roleFilter, setRoleFilter] = useState("");
+ const [availabilityFilter, setAvailabilityFilter] = useState("");
+ const [addModalOpen, setAddModalOpen] = useState(false);
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [modalOpen, setModalOpen] = useState(false);
+
+ useEffect(() => {
+ async function fetchData() {
+ setIsLoading(true);
+ try {
+ const users = await apiFetchApprovedUsersAsync();
+ console.log(users);
+ setAllUsers(users);
+ } catch (err) {
+ console.error("Greška pri dohvaćanju korisnika:", err);
+ }
+ setIsLoading(false);
+ }
+ fetchData();
+ }, []);
+
+ const filteredUsers = allUsers.filter((user) => {
+ const matchesSearch =
+ user.userName.toLowerCase().includes(searchTerm.toLowerCase()) ||
+ user.email.toLowerCase().includes(searchTerm.toLowerCase());
+
+ const matchesRole =
+ roleFilter === "" || user.role?.toLowerCase() === roleFilter.toLowerCase();
+
+ const matchesAvailability =
+ availabilityFilter === "" ||
+ user.availability?.toLowerCase() === availabilityFilter.toLowerCase();
+
+ return matchesSearch && matchesRole && matchesAvailability;
+ });
+
+ const totalPages = Math.max(1, Math.ceil(filteredUsers.length / usersPerPage));
+ const indexOfLastUser = currentPage * usersPerPage;
+ const indexOfFirstUser = indexOfLastUser - usersPerPage;
+ const currentUsers = filteredUsers.slice(indexOfFirstUser, indexOfLastUser);
+
+ const handleDelete = async (userId) => {
+ try {
+ await apiDeleteUserAsync(userId);
+ console.log(`User with ID ${userId} deleted successfully.`);
+ setAllUsers(allUsers.filter((u) => u.id !== userId));
+ if (currentPage > 1 && currentUsers.length === 1) {
+ setCurrentPage(currentPage - 1);
+ }
+ } catch (error) {
+ console.error(`Failed to delete user ${userId}:`, error);
+ }
+ };
+
+ const handleAddUser = () => setAddModalOpen(true);
+
+ const handleSaveUser = async (newUser) => {
+ try {
+ const createdUser = await apiCreateUserAsync({
+ email: newUser.email,
+ password: newUser.password,
+ userName: newUser.userName,
+ });
+ setAllUsers((prev) => [...prev, createdUser.data]);
+ } catch (error) {
+ console.error("Error creating user:", error);
+ }
+ };
+
+ const handlePageChange = (newPage) => {
+ if (newPage >= 1 && newPage <= totalPages) {
+ setCurrentPage(newPage);
+ }
+ };
+
+ const handleViewUser = (userId) => {
+ const user = allUsers.find((u) => u.id === userId);
+ setSelectedUser(user);
+ setModalOpen(true);
+ };
+
+ const handleEditUser = (updatedUser) => {
+ setAllUsers((prevUsers) =>
+ prevUsers.map((user) => (user.id === updatedUser.id ? updatedUser : user))
+ );
+ };
+
+ if (isLoading) return Loading... ;
+
+ return (
+
+
+
+
+
+
+
+
+ setModalOpen(false)}
+ user={selectedUser}
+ />
+
+ setAddModalOpen(false)}
+ onCreate={handleSaveUser}
+ />
+
+
+ );
+};
+
+export default UsersManagements;
diff --git a/src/routes/.gitkeep b/src/routes/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/routes/Router.jsx b/src/routes/Router.jsx
new file mode 100644
index 0000000..f882e09
--- /dev/null
+++ b/src/routes/Router.jsx
@@ -0,0 +1,198 @@
+import React from 'react';
+import {
+ BrowserRouter as Router,
+ Routes,
+ Route,
+ Navigate,
+} from 'react-router-dom';
+import LoginPage from '@pages/LoginPage';
+import UsersManagement from '@pages/UsersManagement';
+import PendingUsersPage from '@pages/PendingUsersPage';
+import { ThemeProvider } from '@mui/material/styles';
+import StoresPage from '@pages/StoresPage';
+import CssBaseline from '@mui/material/CssBaseline';
+import theme from '@styles/theme';
+import Sidebar from '@components/Sidebar';
+import CategoriesPage from '@pages/CategoriesPage';
+import OrdersPage from '@pages/OrdersPage';
+import AnalyticsPage from '@pages/AnalyticsPage';
+import AdPage from '@pages/AdPage';
+import SellerAnalyticsPage from '@pages/SellerAnalyticsPage';
+import ChatPage from '@pages/ChatPage';
+import RoutesPage from '@pages/RoutesPage';
+import RoutesPage2 from '../pages/DelRoutePage';
+import LanguageManagementPage from '../pages/LanguageManagementPage';
+
+const isAuthenticated = () => {
+ console.log(localStorage.getItem('auth'));
+ return localStorage.getItem('auth');
+};
+
+const ProtectedRoute = ({ children }) => {
+ return isAuthenticated() ? children : ;
+};
+
+const Layout = ({ children }) => (
+
+
+ {children}
+
+);
+
+const AppRoutes = () => {
+ return (
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+ {/* */}
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+ }
+ />
+
+ }
+ />
+ } />
+
+
+ );
+};
+
+export default AppRoutes;
diff --git a/src/sections/.gitkeep b/src/sections/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/sections/AdminChatSection.jsx b/src/sections/AdminChatSection.jsx
new file mode 100644
index 0000000..4f86094
--- /dev/null
+++ b/src/sections/AdminChatSection.jsx
@@ -0,0 +1,122 @@
+import { Box, Paper } from '@mui/material';
+import { useState, useEffect } from 'react';
+import ChatHeader from '@components/ChatHeader';
+import ChatMessages from '@components/ChatMessages';
+import ChatInput from '@components/ChatInput';
+import UserInfoSidebar from '@components/UserInfoSidebar';
+import LockOverlay from '@components/LockOverlay';
+import { useSignalR } from '@hooks/useSignalR';
+import { apiFetchMessagesForConversationAsync } from '../api/api.js';
+import { useTranslation } from 'react-i18next';
+
+export default function AdminChatSection({ ticket, conversation }) {
+ const [messages, setMessages] = useState([]);
+ const { t } = useTranslation();
+ const adminUserId = conversation?.adminUserId;
+
+ // Prikaz korisnika
+ let userLabel = '';
+ if (
+ conversation?.buyerUserId &&
+ ticket?.userId === conversation.buyerUserId
+ ) {
+ userLabel = conversation.buyerUsername;
+ } else if (
+ conversation?.sellerUserId &&
+ ticket?.userId === conversation.sellerUserId
+ ) {
+ userLabel = conversation.sellerUsername;
+ }
+
+ // Dohvati poruke
+ useEffect(() => {
+ const fetchMessages = async () => {
+ if (!conversation?.id || ticket?.status !== 'Open') return;
+ const { data } = await apiFetchMessagesForConversationAsync(
+ conversation.id
+ );
+ setMessages(
+ data.map((msg) => ({
+ ...msg,
+ isOwnMessage: msg.senderUserId === adminUserId,
+ }))
+ );
+ };
+ fetchMessages();
+ }, [conversation, ticket?.status, adminUserId]);
+
+ // SignalR za real-time poruke
+ const { messages: signalRMessages, sendMessage } = useSignalR(
+ ticket?.status === 'Open' && conversation ? conversation.id : null,
+ adminUserId
+ );
+
+ // Dodaj nove SignalR poruke u listu
+ useEffect(() => {
+ if (
+ signalRMessages &&
+ signalRMessages.length > 0 &&
+ ticket?.status === 'Open'
+ ) {
+ setMessages((prev) => [
+ ...prev,
+ signalRMessages[signalRMessages.length - 1],
+ ]);
+ }
+ }, [signalRMessages, ticket?.status]);
+
+ // Kada pošalješ poruku, odmah je dodaj u listu (optimistic update)
+ const handleSendMessage = (content) => {
+ if (content.trim() && ticket?.status === 'Open') {
+ sendMessage(content);
+ }
+ };
+
+ const locked = ticket?.status !== 'Open';
+
+ return (
+
+
+
+
+
+ {locked && (
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/src/sections/AdsManagementHeader.jsx b/src/sections/AdsManagementHeader.jsx
new file mode 100644
index 0000000..08c11e8
--- /dev/null
+++ b/src/sections/AdsManagementHeader.jsx
@@ -0,0 +1,78 @@
+import React from 'react';
+import {
+ Box,
+ Typography,
+ Button,
+ TextField,
+ InputAdornment,
+} from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import { useTranslation } from 'react-i18next';
+
+const AdsManagementHeader = ({ onCreateAd, searchTerm, setSearchTerm }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('ads.adsManagement')}
+
+
+ {t('ads.adminPanel')} > {t('ads.advertisements')}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{
+ borderRadius: 2,
+ backgroundColor: '#f9f9f9',
+ minWidth: { xs: '100%', sm: '240px' },
+ }}
+ />
+
+
+
+ );
+};
+
+export default AdsManagementHeader;
diff --git a/src/sections/CategoriesHeader.jsx b/src/sections/CategoriesHeader.jsx
new file mode 100644
index 0000000..cbd564c
--- /dev/null
+++ b/src/sections/CategoriesHeader.jsx
@@ -0,0 +1,78 @@
+import React from "react";
+import {
+ Box,
+ Typography,
+ TextField,
+ InputAdornment,
+ Button,
+} from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import { useTranslation } from 'react-i18next';
+
+const CategoriesHeader = ({ searchTerm, setSearchTerm, onAddCategory }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('categories.categories')}
+
+
+ {t('common.adminPanel')} > {t('categories.categories')}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{
+ borderRadius: 2,
+ backgroundColor: "#f9f9f9",
+ minWidth: { xs: "100%", sm: "240px" },
+ }}
+ />
+
+
+
+
+ );
+};
+
+export default CategoriesHeader;
diff --git a/src/sections/LoginFormSection.jsx b/src/sections/LoginFormSection.jsx
new file mode 100644
index 0000000..eb00a53
--- /dev/null
+++ b/src/sections/LoginFormSection.jsx
@@ -0,0 +1,95 @@
+import React, { useEffect } from "react";
+import Box from "@mui/material/Box";
+import Typography from "@mui/material/Typography";
+import CustomTextField from "../components/CustomTextField";
+import CustomButton from "../components/CustomButton";
+import SocialLoginButton from "../components/SocialLoginButton";
+import { formContainer } from "./LoginFormSectionStyles";
+import { FcGoogle } from "react-icons/fc";
+import { FaFacebookF } from "react-icons/fa";
+import { validateEmail } from "../utils/validation";
+import { useState } from "react";
+// import apiClientInstance from '../api/apiClientInstance'; // Import configured client
+// import { AdminApi, TestAuthApi } from '../api/api/AdminApi';
+import { apiLoginUserAsync } from "../api/api.js";
+import { useNavigate } from "react-router-dom";
+import axios from "axios";
+import { api } from "../utils/apiroutes";
+import { useTranslation } from 'react-i18next';
+
+const LoginFormSection = () => {
+ const { t } = useTranslation();
+ var baseURL = import.meta.env.VITE_API_BASE_URL;
+ const [email, setEmail] = useState("");
+ const [password, setPassword] = useState("");
+ const { isValid, error } = validateEmail(email);
+
+ const navigate = useNavigate();
+
+ const handleLogIn = () => {
+ const status = apiLoginUserAsync(email, password);
+ if (status !== false) navigate("/users");
+ };
+
+ async function handleSubmit(event) {
+ event.preventDefault();
+
+ // const loginPayload = {
+ // email: email,
+ // password: password,
+ // };
+
+ // console.log(baseURL)
+ // console.log(import.meta.env);
+ // axios
+ // .post(`${baseURL}/api/Auth/login`, loginPayload)
+
+ // .then((response) => {
+ // const token = response.data.token;
+
+ // localStorage.setItem("token", token);
+ // localStorage.setItem("auth", true);
+ // if (token) {
+ // axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;
+ // }
+
+ // navigate("/users");
+ // })
+ // .catch((err) => console.log(err));
+ apiLoginUserAsync(email, password).then(() => {
+ navigate("/users");
+ });
+ }
+
+ return (
+
+
+ {t('common.welcome')}
+
+
+ {t('common.loginToContinue')}
+
+ setEmail(e.target.value)}
+ error={email.length > 0 && !isValid}
+ helperText={email.length > 0 && error}
+ />
+ setPassword(p.target.value)}
+ />
+
+
+ {t('common.login')}
+
+
+ );
+};
+
+export default LoginFormSection;
diff --git a/src/sections/LoginFormSectionStyles.jsx b/src/sections/LoginFormSectionStyles.jsx
new file mode 100644
index 0000000..4baf633
--- /dev/null
+++ b/src/sections/LoginFormSectionStyles.jsx
@@ -0,0 +1,16 @@
+export const formContainer = {
+ padding: 4,
+ width: '100%',
+ maxWidth: 400,
+ margin: 'auto',
+ backgroundColor: '#FAF9F6',
+ borderRadius: 3,
+ boxShadow: 3,
+ };
+
+ export const socialButtonsWrapper = {
+ display: 'flex',
+ gap: 1,
+ justifyContent: 'center',
+ };
+
\ No newline at end of file
diff --git a/src/sections/OrdersHeader.jsx b/src/sections/OrdersHeader.jsx
new file mode 100644
index 0000000..e323f2a
--- /dev/null
+++ b/src/sections/OrdersHeader.jsx
@@ -0,0 +1,93 @@
+import React from 'react';
+import {
+ Box,
+ Typography,
+ TextField,
+ InputAdornment,
+ Button,
+} from '@mui/material';
+import SearchIcon from '@mui/icons-material/Search';
+import { FormControl, InputLabel, Select, MenuItem } from '@mui/material';
+import { useTranslation } from 'react-i18next';
+
+const OrdersHeader = ({
+ searchTerm,
+ setSearchTerm,
+ statusFilter,
+ setStatusFilter,
+}) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('common.orders')}
+
+
+ {t('common.adminPanel')} > {t('common.orders')}
+
+
+
+
+ {/* 🔽 Filter by Status */}
+
+ {t('common.status')}
+
+
+
+ {/* 🔍 Search */}
+ setSearchTerm(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{
+ borderRadius: 2,
+ backgroundColor: '#f9f9f9',
+ minWidth: { xs: '100%', sm: '240px' },
+ }}
+ />
+
+
+ );
+};
+
+export default OrdersHeader;
diff --git a/src/sections/PendingUsersHeader.jsx b/src/sections/PendingUsersHeader.jsx
new file mode 100644
index 0000000..d4be3df
--- /dev/null
+++ b/src/sections/PendingUsersHeader.jsx
@@ -0,0 +1,51 @@
+import React from "react";
+import { Box, Typography, TextField, InputAdornment } from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import { useTranslation } from 'react-i18next';
+
+const PendingUsersHeader = ({ onAddUser, searchTerm, setSearchTerm }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('common.requests')}
+
+
+ {t('common.adminPanel')} > {t('common.requests')}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{ borderRadius: 2, backgroundColor: "#f9f9f9" }}
+ />
+
+
+ );
+};
+
+export default PendingUsersHeader;
diff --git a/src/sections/PendingUsersSection.jsx b/src/sections/PendingUsersSection.jsx
new file mode 100644
index 0000000..4ffd572
--- /dev/null
+++ b/src/sections/PendingUsersSection.jsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import PendingUsersTable from '@components/PendingUsersTable';
+
+const PendingUsersSection = ({
+ users,
+ onApprove,
+ onDelete,
+ currentPage,
+ usersPerPage,
+}) => {
+ return (
+ <>
+
+ >
+ );
+};
+
+export default PendingUsersSection;
diff --git a/src/sections/RoutesHeader.jsx b/src/sections/RoutesHeader.jsx
new file mode 100644
index 0000000..015de01
--- /dev/null
+++ b/src/sections/RoutesHeader.jsx
@@ -0,0 +1,56 @@
+import React from "react";
+import {
+ Box,
+ Typography,
+ Button,
+ TextField,
+ InputAdornment,
+} from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import { useTranslation } from 'react-i18next';
+
+const RoutesHeader = ({ onAddRoute }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('common.allRoutes')}
+
+
+ {t('common.adminPanel')} > {t('common.routes')}
+
+
+
+
+ );
+};
+
+export default RoutesHeader;
diff --git a/src/sections/StoresHeader.jsx b/src/sections/StoresHeader.jsx
new file mode 100644
index 0000000..2cbe1a5
--- /dev/null
+++ b/src/sections/StoresHeader.jsx
@@ -0,0 +1,78 @@
+import React from "react";
+import {
+ Box,
+ Typography,
+ TextField,
+ InputAdornment,
+ Button,
+} from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import { useTranslation } from 'react-i18next';
+
+const StoresHeader = ({ searchTerm, setSearchTerm, onAddStore }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('stores.stores')}
+
+
+ {t('common.adminPanel')} > {t('stores.stores')}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{
+ borderRadius: 2,
+ backgroundColor: "#f9f9f9",
+ minWidth: { xs: "100%", sm: "240px" },
+ }}
+ />
+
+
+
+
+ );
+};
+
+export default StoresHeader;
diff --git a/src/sections/UserCreateSection.jsx b/src/sections/UserCreateSection.jsx
new file mode 100644
index 0000000..aae85c5
--- /dev/null
+++ b/src/sections/UserCreateSection.jsx
@@ -0,0 +1,127 @@
+import React, { useState } from 'react';
+import {
+ Box,
+ Typography,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ Snackbar,
+ Alert
+} from '@mui/material';
+import ValidatedTextField from '../components/ValidatedTextField';
+import CustomButton from '../components/CustomButton';
+import { useTranslation } from 'react-i18next';
+
+const UserCreateSection = () => {
+ const { t } = useTranslation();
+ const [formData, setFormData] = useState({
+ name: '',
+ email: '',
+ password: '',
+ role: 'buyer',
+ });
+
+ const [errors, setErrors] = useState({});
+ const [snackbarOpen, setSnackbarOpen] = useState(false);
+
+ const validate = () => {
+ const newErrors = {};
+ if (!formData.name.trim()) newErrors.name = 'Name is required';
+ if (!formData.email.trim()) newErrors.email = 'Email is required';
+ else if (!/\S+@\S+\.\S+/.test(formData.email)) newErrors.email = 'Invalid email';
+ if (!formData.password.trim()) newErrors.password = 'Password is required';
+ else if (formData.password.length < 6) newErrors.password = 'Minimum 6 characters';
+
+ setErrors(newErrors);
+ return Object.keys(newErrors).length === 0;
+ };
+
+ const handleChange = (e) => {
+ setFormData({ ...formData, [e.target.name]: e.target.value });
+ setErrors({ ...errors, [e.target.name]: '' });
+ };
+
+ const handleSubmit = () => {
+ if (!validate()) return;
+
+ //temporary
+ console.log('User created:', formData);
+ setSnackbarOpen(true);
+ setFormData({ name: '', email: '', password: '', role: 'buyer' });
+ };
+
+ return (
+
+
+ {t('common.createNewUser')}
+
+
+
+
+
+
+
+
+
+ {t('common.role')}
+
+
+
+ {t('common.createUser')}
+
+ setSnackbarOpen(false)}
+ anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
+ >
+ setSnackbarOpen(false)}
+ severity="success"
+ sx={{ width: '100%' }}
+ >
+ {t('common.userCreatedSuccessfully')}
+
+
+
+ );
+};
+
+export default UserCreateSection;
diff --git a/src/sections/UserDetailsSection.jsx b/src/sections/UserDetailsSection.jsx
new file mode 100644
index 0000000..1293188
--- /dev/null
+++ b/src/sections/UserDetailsSection.jsx
@@ -0,0 +1,80 @@
+import { Card, CardContent, Typography, Button, Box } from "@mui/material";
+import React, { useState, useEffect } from "react";
+
+import UserAvatar from "../components/UserAvatar.jsx";
+import UserName from "../components/UserName.jsx";
+import UserEmail from "../components/UserEmail.jsx";
+import UserPhone from "../components/UserPhone.jsx";
+import UserRoles from "../components/UserRoles.jsx";
+import UserEditForm from "../components/userEditForm.jsx";
+
+import { getUsers, updateUser } from "../data/usersDetails.js";
+import { useTranslation } from 'react-i18next';
+
+const UserDetailsSection = () => {
+ const [selectedUser, setSelectedUser] = useState(null);
+ const [isEditing, setIsEditing] = useState(false);
+ const { t } = useTranslation();
+
+ useEffect(() => {
+ const user = getUsers()[0];
+ setSelectedUser(user);
+ }, []);
+
+ const handleEditToggle = () => {
+ setIsEditing(!isEditing);
+ };
+
+ const handleUserSave = (updatedUser) => {
+ setSelectedUser(updatedUser);
+ setIsEditing(false);
+ };
+
+ if (!selectedUser) {
+ return {t('common.loadingUserData')} ;
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isEditing && (
+
+ )}
+
+
+
+ );
+};
+
+export default UserDetailsSection;
diff --git a/src/sections/UserManagementHeader.jsx b/src/sections/UserManagementHeader.jsx
new file mode 100644
index 0000000..806d134
--- /dev/null
+++ b/src/sections/UserManagementHeader.jsx
@@ -0,0 +1,77 @@
+import React from "react";
+import {
+ Box,
+ Typography,
+ Button,
+ TextField,
+ InputAdornment,
+} from "@mui/material";
+import SearchIcon from "@mui/icons-material/Search";
+import { useTranslation } from 'react-i18next';
+
+const UserManagementHeader = ({ onAddUser, searchTerm, setSearchTerm }) => {
+ const { t } = useTranslation();
+ return (
+
+
+
+ {t('common.userManagement')}
+
+
+ {t('common.adminPanel')} > {t('common.userManagement')}
+
+
+
+
+ setSearchTerm(e.target.value)}
+ InputProps={{
+ startAdornment: (
+
+
+
+ ),
+ }}
+ sx={{
+ borderRadius: 2,
+ backgroundColor: "#f9f9f9",
+ minWidth: { xs: "100%", sm: "240px" },
+ }}
+ />
+
+
+
+ );
+};
+
+export default UserManagementHeader;
diff --git a/src/sections/UserManagementSection.jsx b/src/sections/UserManagementSection.jsx
new file mode 100644
index 0000000..7dc68e3
--- /dev/null
+++ b/src/sections/UserManagementSection.jsx
@@ -0,0 +1,82 @@
+import React, { useState } from 'react';
+import { Box } from '@mui/material';
+import UserList from '../components/UserList.jsx';
+import ConfirmDialog from '../components/ConfirmDialog.jsx';
+import UserDetailsModal from '@components/UserDetailsModal';
+import { apiUpdateUserAsync, apiToggleUserAvailabilityAsync } from '@api/api';
+import { useTranslation } from 'react-i18next';
+
+export default function UserManagementSection({
+ allUsers,
+ currentPage,
+ usersPerPage,
+ onDelete,
+ onView,
+ onEdit,
+ setAllUsers,
+}) {
+ const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
+ const [userToDelete, setUserToDelete] = useState(null);
+ const { t } = useTranslation();
+
+ const handleDelete = (userId) => {
+ setUserToDelete(userId);
+ setConfirmDialogOpen(true);
+ };
+
+ const confirmDelete = () => {
+ onDelete(userToDelete);
+ setConfirmDialogOpen(false);
+ setUserToDelete(null);
+ };
+
+ const cancelDelete = () => {
+ setConfirmDialogOpen(false);
+ setUserToDelete(null);
+ };
+
+ const handleEdit = async (updatedUser) => {
+ try {
+ if (updatedUser.toggleAvailabilityOnly) {
+ onEdit(updatedUser);
+
+ const response = await apiToggleUserAvailabilityAsync(
+ updatedUser.id,
+ updatedUser.isActive
+ );
+ console.log('RESP:', response);
+
+ if (response.statusText === 'OK') {
+ onEdit({ ...updatedUser, isActive: updatedUser.isActive });
+ }
+ } else {
+ const response = await apiUpdateUserAsync(updatedUser);
+ if (response.status !== 400) {
+ onEdit(response.data);
+ }
+ }
+ } catch (err) {
+ console.error('Error editing user:', err);
+ }
+ };
+
+ return (
+
+
+
+
+
+ );
+}
diff --git a/src/services/.gitkeep b/src/services/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/store/.gitkeep b/src/store/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/styles/.gitkeep b/src/styles/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/styles/theme.js b/src/styles/theme.js
new file mode 100644
index 0000000..d2b916e
--- /dev/null
+++ b/src/styles/theme.js
@@ -0,0 +1,41 @@
+// theme.js
+import { createTheme } from '@mui/material/styles';
+
+const theme = createTheme({
+ palette: {
+ primary: {
+ main: "#3C5B66",
+ contrastText: "#FFFFFF",
+ },
+ secondary: {
+ main: "#D7A151",
+ contrastText: "#FFFFFF",
+ },
+ error: {
+ main: "#923330",
+ },
+ text: {
+ primary: "#4D1211",
+ secondary: "#3C5B66",
+ },
+ },
+ typography: {
+ fontFamily: "Manrope, sans-serif",
+ h1: { fontWeight: 700 },
+ h2: { fontWeight: 700 },
+ h3: { fontWeight: 600 },
+ h4: { fontWeight: 600 },
+ h5: { fontWeight: 500 },
+ h6: { fontWeight: 500 },
+ body1: { fontWeight: 400 },
+ button: {
+ textTransform: "none",
+ fontWeight: 600,
+ },
+ },
+ shape: {
+ borderRadius: 5,
+ },
+});
+
+export default theme;
diff --git a/src/theme.js b/src/theme.js
new file mode 100644
index 0000000..eeb464b
--- /dev/null
+++ b/src/theme.js
@@ -0,0 +1,149 @@
+import { createTheme } from '@mui/material/styles';
+
+const theme = createTheme({
+ palette: {
+ primary: {
+ main: '#2563EB',
+ light: '#93C5FD',
+ dark: '#1E40AF',
+ contrastText: '#FFFFFF',
+ },
+ secondary: {
+ main: '#0D9488',
+ light: '#99F6E4',
+ dark: '#0F766E',
+ contrastText: '#FFFFFF',
+ },
+ success: {
+ main: '#10B981',
+ light: '#A7F3D0',
+ dark: '#047857',
+ },
+ warning: {
+ main: '#F59E0B',
+ light: '#FDE68A',
+ dark: '#B45309',
+ },
+ error: {
+ main: '#EF4444',
+ light: '#FCA5A5',
+ dark: '#B91C1C',
+ },
+ info: {
+ main: '#3B82F6',
+ light: '#BFDBFE',
+ dark: '#1E40AF',
+ },
+ grey: {
+ 50: '#F9FAFB',
+ 100: '#F3F4F6',
+ 200: '#E5E7EB',
+ 300: '#D1D5DB',
+ 400: '#9CA3AF',
+ 500: '#6B7280',
+ 600: '#4B5563',
+ 700: '#374151',
+ 800: '#1F2937',
+ 900: '#111827',
+ },
+ background: {
+ default: '#F9FAFB',
+ paper: '#FFFFFF',
+ },
+ text: {
+ primary: '#111827',
+ secondary: '#4B5563',
+ disabled: '#9CA3AF',
+ },
+ },
+ typography: {
+ fontFamily: '"Inter", "Helvetica", "Arial", sans-serif',
+ h1: {
+ fontSize: '2.5rem',
+ fontWeight: 700,
+ lineHeight: 1.2,
+ },
+ h2: {
+ fontSize: '2rem',
+ fontWeight: 700,
+ lineHeight: 1.2,
+ },
+ h3: {
+ fontSize: '1.75rem',
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ h4: {
+ fontSize: '1.5rem',
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ h5: {
+ fontSize: '1.25rem',
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ h6: {
+ fontSize: '1rem',
+ fontWeight: 600,
+ lineHeight: 1.2,
+ },
+ body1: {
+ fontSize: '1rem',
+ lineHeight: 1.5,
+ },
+ body2: {
+ fontSize: '0.875rem',
+ lineHeight: 1.5,
+ },
+ subtitle1: {
+ fontSize: '1rem',
+ fontWeight: 500,
+ lineHeight: 1.5,
+ },
+ subtitle2: {
+ fontSize: '0.875rem',
+ fontWeight: 500,
+ lineHeight: 1.5,
+ },
+ },
+ shape: {
+ borderRadius: 8,
+ },
+ shadows: [
+ 'none',
+ '0px 1px 2px rgba(0, 0, 0, 0.06), 0px 1px 3px rgba(0, 0, 0, 0.1)',
+ '0px 2px 4px rgba(0, 0, 0, 0.06), 0px 4px 6px rgba(0, 0, 0, 0.1)',
+ '0px 4px 6px rgba(0, 0, 0, 0.05), 0px 10px 15px rgba(0, 0, 0, 0.1)',
+ '0px 10px 15px rgba(0, 0, 0, 0.04), 0px 20px 25px rgba(0, 0, 0, 0.1)',
+ // ... rest of the shadows
+ ],
+ components: {
+ MuiButton: {
+ styleOverrides: {
+ root: {
+ textTransform: 'none',
+ fontWeight: 600,
+ padding: '8px 16px',
+ },
+ },
+ },
+ MuiCard: {
+ styleOverrides: {
+ root: {
+ boxShadow: '0px 4px 6px rgba(0, 0, 0, 0.05), 0px 10px 15px rgba(0, 0, 0, 0.1)',
+ borderRadius: '12px',
+ },
+ },
+ },
+ MuiPaper: {
+ styleOverrides: {
+ root: {
+ borderRadius: '12px',
+ },
+ },
+ },
+ },
+});
+
+export default theme;
\ No newline at end of file
diff --git a/src/utils/.gitkeep b/src/utils/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/src/utils/apiroutes.js b/src/utils/apiroutes.js
new file mode 100644
index 0000000..bfb2e5c
--- /dev/null
+++ b/src/utils/apiroutes.js
@@ -0,0 +1,10 @@
+const server = import.meta.env.VITE_API_URL;
+
+
+export const api = {
+ login: `${server}/api/Auth/login`,
+ users: `${server}/api/Admin/users`,
+ delete_user: `${server}/api/Admin/user/`,
+ create_user: `${server}/api/Admin/users/create`,
+ approve_user: `${server}/api/Admin/users/approve`
+}
\ No newline at end of file
diff --git a/src/utils/axios.js b/src/utils/axios.js
new file mode 100644
index 0000000..a6740c4
--- /dev/null
+++ b/src/utils/axios.js
@@ -0,0 +1,11 @@
+import axios from 'axios';
+
+const token = localStorage.getItem('token');
+
+if (token) {
+ axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
+}
+
+axios.defaults.baseURL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5054';
+
+export default axios;
\ No newline at end of file
diff --git a/src/utils/login.js b/src/utils/login.js
new file mode 100644
index 0000000..4547fc1
--- /dev/null
+++ b/src/utils/login.js
@@ -0,0 +1,41 @@
+/*// src/services/authService.js
+import apiClientInstance from '../api/apiClientInstance';
+// *** IMPORTANT: Use the correct API class name based on your HAR log ***
+import TestAuthApi from '../api/api/TestAuthApi'; // <<< Make sure this matches your generated file name
+import LoginDTO from '../api/model/LoginDTO';
+
+export const loginUser = async (username, password) => {
+ // Use the correct API class
+ const testAuthApi = new TestAuthApi(apiClientInstance); // <<< Use correct class instance
+
+ const loginPayload = new LoginDTO();
+ loginPayload.username = username;
+ loginPayload.password = password;
+
+ console.log("Attempting login via TestAuthApi for:", username);
+
+ return new Promise((resolve, reject) => {
+ try {
+ // *** IMPORTANT: Check the method name in generated TestAuthApi.js ***
+ // It might be testAuthLoginPost, apiTestAuthLoginPost, etc.
+ testAuthApi.apiTestAuthLoginPost(loginPayload, (error, data, response) => { // <<< Use correct method name
+ if (error) {
+ console.error('Login API Error:', error);
+ console.error('Login API Response:', response);
+ const status = error?.status || response?.status || null;
+ let message = 'Login failed. Please check your credentials.';
+ return false;
+ //reject({ message: message, status: status }); // TODO: Lijepo prikazati
+ } else {
+ console.log('Login successful via API.');
+ console.log('API Response Data:', data);
+ localStorage.setItem('auth', true);
+ resolve(data);
+ }
+ });
+ } catch (err) {
+ console.error('Synchronous error calling login API:', err);
+ reject({ message: err.message || 'An unexpected error occurred.', status: null });
+ }
+ });
+};*/
\ No newline at end of file
diff --git a/src/utils/users.js b/src/utils/users.js
new file mode 100644
index 0000000..9e2af5a
--- /dev/null
+++ b/src/utils/users.js
@@ -0,0 +1,68 @@
+// src/services/adminService.js
+
+import apiClientInstance from '../api/apiClientInstance'; // Your configured instance
+// Adjust the import path based on your generation
+import AdminApi from '../api/api/AdminApi';
+
+/**
+ * Fetches the list of all users from the admin endpoint.
+ * Assumes the user is authenticated via HttpOnly cookie.
+ * Returns the array of user data on success.
+ * Throws an error object { message: string, status: number | null } on failure.
+ *
+ * @returns {Promise>} A promise that resolves with the array of users on success.
+ * @throws {object} An error object with message and status on failure (e.g., 401, 403, 500).
+ */
+export const fetchAdminUsers = async () => {
+ // Instantiate the specific API class using the configured client
+ const adminApi = new AdminApi(apiClientInstance);
+
+ console.log("Attempting to fetch admin users..."); // Debug log
+
+ // Wrap the generated callback method in a Promise
+ return new Promise((resolve, reject) => {
+ try {
+ // Call the generated method for GET /api/Admin/users.
+ // *** IMPORTANT: Check your generated AdminApi.js ***
+ // The method name might be getUsers, apiAdminUsersGet, listAdminUsers, etc.
+ // It typically only takes the callback function as an argument for a simple GET.
+ adminApi.apiAdminUsersGet((error, data, response) => { // <<< Use correct method name
+ if (error) {
+ // Handle API errors (e.g., 401 Unauthorized, 403 Forbidden, 500 Server Error)
+ console.error('Fetch Admin Users API Error:', error);
+ console.error('Fetch Admin Users API Response:', response);
+
+ const status = error?.status || response?.status || null;
+ let message = 'Failed to fetch users.'; // Default message
+
+ // Customize message based on status
+ if (status === 401) {
+ message = 'Unauthorized. Please log in again.';
+ } else if (status === 403) {
+ message = 'Forbidden. You do not have permission to view users.';
+ } else if (status >= 500) {
+ message = 'Server error fetching users. Please try again later.';
+ } else if (error?.message) {
+ message = error.message;
+ } else if (response?.text) {
+ try { message = JSON.parse(response.text).detail || JSON.parse(response.text).title || message } catch (e) { message = response.text.substring(0, 100) || message }
+ }
+
+ reject({ message: message, status: status });
+ } else {
+ // Request successful!
+ console.log('Successfully fetched admin users.');
+ console.log('API Response Data:', data);
+ // Resolve the promise with the user data array
+ resolve(data || []); // Ensure we return an array even if API returns null/undefined
+ }
+ });
+ } catch (err) {
+ // Catch synchronous errors during API call setup
+ console.error('Synchronous error calling fetch users API:', err);
+ reject({ message: err.message || 'An unexpected error occurred.', status: null });
+ }
+ });
+};
+
+// Add other admin-related service functions here (e.g., approveUser, deleteUser)
\ No newline at end of file
diff --git a/src/utils/validation.js b/src/utils/validation.js
new file mode 100644
index 0000000..be445dd
--- /dev/null
+++ b/src/utils/validation.js
@@ -0,0 +1,28 @@
+import { z } from 'zod';
+
+export const emailSchema = z.object({
+ email: z.string()
+ .min(1, { message: 'Email is required' })
+ .email({ message: 'Please enter a valid email address' })
+ .refine((email) => {
+ return true;
+ }, { message: 'Invalid email format' })
+});
+
+export const validateEmail = (email) => {
+ try {
+ emailSchema.parse({ email });
+ return { isValid: true, error: null };
+ } catch (error) {
+ if (error instanceof z.ZodError) {
+ return {
+ isValid: false,
+ error: error.errors[0].message
+ };
+ }
+ return {
+ isValid: false,
+ error: 'An unexpected error occurred'
+ };
+ }
+};
\ No newline at end of file
diff --git a/vite.config.js b/vite.config.js
index 8b0f57b..59fa629 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -4,4 +4,27 @@ import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
+ resolve: {
+ alias: {
+ "@src": "/src",
+ "@assets": "/src/assets",
+ "@fonts": "/src/assets/fonts",
+ "@icons": "/src/assets/icons",
+ "@images": "/src/assets/images",
+ "@components": "/src/components",
+ "@pages": "/src/pages",
+ "@data": "/src/data",
+ "@hooks": "/src/hooks",
+ "@routes": "/src/routes",
+ "@sections": "/src/sections",
+ "@styles": "/src/styles",
+ "@utils": "/src/utils",
+ "@store": "/src/store",
+ "@services": "/src/services",
+ "@context": "/src/context",
+ '@api': "/src/api",
+ '@models': "/src/models"
+
+ },
+ },
})