commit 0ed6bed028f4cdb542d212e4ffb6d93671ece13c Author: s.esmaeili Date: Wed Sep 9 06:46:27 2026 +0330 feat: initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ec8fe0a --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +dist/ +.turbo/ +.env +.env.* +!.env.example +*.log +.DS_Store +apps/api/generated/ diff --git a/apps/admin-panel/index.html b/apps/admin-panel/index.html new file mode 100644 index 0000000..511af28 --- /dev/null +++ b/apps/admin-panel/index.html @@ -0,0 +1,12 @@ + + + + + + Gold Bank — Admin Panel + + +
+ + + diff --git a/apps/admin-panel/package.json b/apps/admin-panel/package.json new file mode 100644 index 0000000..2e9e5ab --- /dev/null +++ b/apps/admin-panel/package.json @@ -0,0 +1,22 @@ +{ + "name": "admin-panel", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "lint": "eslint \"src/**/*.{ts,vue}\"" + }, + "dependencies": { + "@gold-bank/shared-types": "workspace:*", + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "pinia": "^2.2.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vue-tsc": "^2.1.0" + } +} diff --git a/apps/admin-panel/src/App.vue b/apps/admin-panel/src/App.vue new file mode 100644 index 0000000..6ea327d --- /dev/null +++ b/apps/admin-panel/src/App.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/apps/admin-panel/src/App.vue.d.ts b/apps/admin-panel/src/App.vue.d.ts new file mode 100644 index 0000000..7711b0c --- /dev/null +++ b/apps/admin-panel/src/App.vue.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; +export default _default; diff --git a/apps/admin-panel/src/App.vue.js b/apps/admin-panel/src/App.vue.js new file mode 100644 index 0000000..3b80295 --- /dev/null +++ b/apps/admin-panel/src/App.vue.js @@ -0,0 +1,39 @@ +/// +debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ +const __VLS_ctx = {}; +let __VLS_components; +let __VLS_directives; +// CSS variable injection +// CSS variable injection end +__VLS_asFunctionalElement(__VLS_intrinsicElements.header, __VLS_intrinsicElements.header)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.nav, __VLS_intrinsicElements.nav)({}); +const __VLS_0 = {}.RouterLink; +/** @type {[typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, typeof __VLS_components.RouterLink, typeof __VLS_components.routerLink, ]} */ ; +// @ts-ignore +const __VLS_1 = __VLS_asFunctionalComponent(__VLS_0, new __VLS_0({ + to: "/", +})); +const __VLS_2 = __VLS_1({ + to: "/", +}, ...__VLS_functionalComponentArgsRest(__VLS_1)); +__VLS_3.slots.default; +var __VLS_3; +__VLS_asFunctionalElement(__VLS_intrinsicElements.main, __VLS_intrinsicElements.main)({}); +const __VLS_4 = {}.RouterView; +/** @type {[typeof __VLS_components.RouterView, typeof __VLS_components.routerView, ]} */ ; +// @ts-ignore +const __VLS_5 = __VLS_asFunctionalComponent(__VLS_4, new __VLS_4({})); +const __VLS_6 = __VLS_5({}, ...__VLS_functionalComponentArgsRest(__VLS_5)); +var __VLS_dollars; +const __VLS_self = (await import('vue')).defineComponent({ + setup() { + return {}; + }, +}); +export default (await import('vue')).defineComponent({ + setup() { + return {}; + }, +}); +; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/src/api/client.d.ts b/apps/admin-panel/src/api/client.d.ts new file mode 100644 index 0000000..b0433f3 --- /dev/null +++ b/apps/admin-panel/src/api/client.d.ts @@ -0,0 +1,48 @@ +import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types'; +export interface CustomerSummary { + id: string; + fullName: string; + mobile: string; + goldDebtGrams: string; + fiatDebtRial: string; + goldCreditLimitGrams: string; +} +export interface ProductVariantSummary { + id: string; + title: string; + rashWeightGrams: string; + defaultWagePercent: string; +} +export interface ProductSummary { + id: string; + title: string; + variants: ProductVariantSummary[]; +} +export interface OrderDetail { + id: string; + orderNumber: string; + status: string; + totalGoldWeightGrams: string; + wageWeightGrams: string; + flatRialCharge: string; + isSuspendedOverCreditLimit: boolean; + deliveredAt: string | null; + items: Array<{ + id: string; + quantity: number; + computedWeightGrams: string; + }>; + statusEvents: Array<{ + toStatus: string; + note: string | null; + createdAt: string; + }>; +} +export declare const api: { + listCustomers: () => Promise; + getCustomerBalance: (id: string) => Promise; + listProducts: () => Promise; + createOrder: (dto: CreateOrderDto) => Promise; + getOrder: (id: string) => Promise; + updateOrderStatus: (id: string, status: string, note?: string) => Promise; +}; diff --git a/apps/admin-panel/src/api/client.js b/apps/admin-panel/src/api/client.js new file mode 100644 index 0000000..5b3eb4c --- /dev/null +++ b/apps/admin-panel/src/api/client.js @@ -0,0 +1,20 @@ +const BASE = '/api'; +async function request(path, options) { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`API ${path} failed (${res.status}): ${body}`); + } + return res.json(); +} +export const api = { + listCustomers: () => request('/customers'), + getCustomerBalance: (id) => request(`/accounting/customers/${id}/balance`), + listProducts: () => request('/catalog/products'), + createOrder: (dto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), + getOrder: (id) => request(`/orders/${id}`), + updateOrderStatus: (id, status, note) => request(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }), +}; diff --git a/apps/admin-panel/src/api/client.ts b/apps/admin-panel/src/api/client.ts new file mode 100644 index 0000000..0d31613 --- /dev/null +++ b/apps/admin-panel/src/api/client.ts @@ -0,0 +1,61 @@ +import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types'; + +const BASE = '/api'; + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`API ${path} failed (${res.status}): ${body}`); + } + return res.json(); +} + +export interface CustomerSummary { + id: string; + fullName: string; + mobile: string; + goldDebtGrams: string; + fiatDebtRial: string; + goldCreditLimitGrams: string; +} + +export interface ProductVariantSummary { + id: string; + title: string; + rashWeightGrams: string; + defaultWagePercent: string; +} + +export interface ProductSummary { + id: string; + title: string; + variants: ProductVariantSummary[]; +} + +export interface OrderDetail { + id: string; + orderNumber: string; + status: string; + totalGoldWeightGrams: string; + wageWeightGrams: string; + flatRialCharge: string; + isSuspendedOverCreditLimit: boolean; + deliveredAt: string | null; + items: Array<{ id: string; quantity: number; computedWeightGrams: string }>; + statusEvents: Array<{ toStatus: string; note: string | null; createdAt: string }>; +} + +export const api = { + listCustomers: () => request('/customers'), + getCustomerBalance: (id: string) => request(`/accounting/customers/${id}/balance`), + listProducts: () => request('/catalog/products'), + createOrder: (dto: CreateOrderDto) => + request('/orders', { method: 'POST', body: JSON.stringify(dto) }), + getOrder: (id: string) => request(`/orders/${id}`), + updateOrderStatus: (id: string, status: string, note?: string) => + request(`/orders/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status, note }) }), +}; diff --git a/apps/admin-panel/src/env.d.ts b/apps/admin-panel/src/env.d.ts new file mode 100644 index 0000000..51c682d --- /dev/null +++ b/apps/admin-panel/src/env.d.ts @@ -0,0 +1,7 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/apps/admin-panel/src/main.d.ts b/apps/admin-panel/src/main.d.ts new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/apps/admin-panel/src/main.d.ts @@ -0,0 +1 @@ +export {}; diff --git a/apps/admin-panel/src/main.js b/apps/admin-panel/src/main.js new file mode 100644 index 0000000..0cf8dfb --- /dev/null +++ b/apps/admin-panel/src/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import { router } from './router'; +createApp(App).use(createPinia()).use(router).mount('#app'); diff --git a/apps/admin-panel/src/main.ts b/apps/admin-panel/src/main.ts new file mode 100644 index 0000000..4bb269d --- /dev/null +++ b/apps/admin-panel/src/main.ts @@ -0,0 +1,6 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import { router } from './router'; + +createApp(App).use(createPinia()).use(router).mount('#app'); diff --git a/apps/admin-panel/src/router/index.d.ts b/apps/admin-panel/src/router/index.d.ts new file mode 100644 index 0000000..b18f1f1 --- /dev/null +++ b/apps/admin-panel/src/router/index.d.ts @@ -0,0 +1 @@ +export declare const router: import("vue-router").Router; diff --git a/apps/admin-panel/src/router/index.js b/apps/admin-panel/src/router/index.js new file mode 100644 index 0000000..5d96f67 --- /dev/null +++ b/apps/admin-panel/src/router/index.js @@ -0,0 +1,12 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import DashboardView from '../views/DashboardView.vue'; +import OrderNewView from '../views/OrderNewView.vue'; +import OrderDetailView from '../views/OrderDetailView.vue'; +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'dashboard', component: DashboardView }, + { path: '/orders/new', name: 'order-new', component: OrderNewView }, + { path: '/orders/:id', name: 'order-detail', component: OrderDetailView }, + ], +}); diff --git a/apps/admin-panel/src/router/index.ts b/apps/admin-panel/src/router/index.ts new file mode 100644 index 0000000..1823cf4 --- /dev/null +++ b/apps/admin-panel/src/router/index.ts @@ -0,0 +1,13 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import DashboardView from '../views/DashboardView.vue'; +import OrderNewView from '../views/OrderNewView.vue'; +import OrderDetailView from '../views/OrderDetailView.vue'; + +export const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'dashboard', component: DashboardView }, + { path: '/orders/new', name: 'order-new', component: OrderNewView }, + { path: '/orders/:id', name: 'order-detail', component: OrderDetailView }, + ], +}); diff --git a/apps/admin-panel/src/stores/session.d.ts b/apps/admin-panel/src/stores/session.d.ts new file mode 100644 index 0000000..d6a6af4 --- /dev/null +++ b/apps/admin-panel/src/stores/session.d.ts @@ -0,0 +1,9 @@ +/** + * Placeholder for the logged-in admin's identity. No auth module exists yet + * (see migration notes) — this just tracks UI selection state until then. + */ +export declare const useSessionStore: import("pinia").StoreDefinition<"session", { + selectedCustomerId: string | null; +}, {}, { + selectCustomer(customerId: string): void; +}>; diff --git a/apps/admin-panel/src/stores/session.js b/apps/admin-panel/src/stores/session.js new file mode 100644 index 0000000..effdc49 --- /dev/null +++ b/apps/admin-panel/src/stores/session.js @@ -0,0 +1,15 @@ +import { defineStore } from 'pinia'; +/** + * Placeholder for the logged-in admin's identity. No auth module exists yet + * (see migration notes) — this just tracks UI selection state until then. + */ +export const useSessionStore = defineStore('session', { + state: () => ({ + selectedCustomerId: null, + }), + actions: { + selectCustomer(customerId) { + this.selectedCustomerId = customerId; + }, + }, +}); diff --git a/apps/admin-panel/src/stores/session.ts b/apps/admin-panel/src/stores/session.ts new file mode 100644 index 0000000..4080d12 --- /dev/null +++ b/apps/admin-panel/src/stores/session.ts @@ -0,0 +1,16 @@ +import { defineStore } from 'pinia'; + +/** + * Placeholder for the logged-in admin's identity. No auth module exists yet + * (see migration notes) — this just tracks UI selection state until then. + */ +export const useSessionStore = defineStore('session', { + state: () => ({ + selectedCustomerId: null as string | null, + }), + actions: { + selectCustomer(customerId: string) { + this.selectedCustomerId = customerId; + }, + }, +}); diff --git a/apps/admin-panel/src/views/DashboardView.vue b/apps/admin-panel/src/views/DashboardView.vue new file mode 100644 index 0000000..f852f35 --- /dev/null +++ b/apps/admin-panel/src/views/DashboardView.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/apps/admin-panel/src/views/DashboardView.vue.d.ts b/apps/admin-panel/src/views/DashboardView.vue.d.ts new file mode 100644 index 0000000..7711b0c --- /dev/null +++ b/apps/admin-panel/src/views/DashboardView.vue.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; +export default _default; diff --git a/apps/admin-panel/src/views/DashboardView.vue.js b/apps/admin-panel/src/views/DashboardView.vue.js new file mode 100644 index 0000000..fc64e05 --- /dev/null +++ b/apps/admin-panel/src/views/DashboardView.vue.js @@ -0,0 +1,97 @@ +/// +import { onMounted, ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { api } from '../api/client'; +import { useSessionStore } from '../stores/session'; +const customers = ref([]); +const loading = ref(true); +const error = ref(null); +const router = useRouter(); +const session = useSessionStore(); +onMounted(async () => { + try { + customers.value = await api.listCustomers(); + } + catch (e) { + error.value = e.message; + } + finally { + loading.value = false; + } +}); +function goToNewOrder(customerId) { + session.selectCustomer(customerId); + router.push({ name: 'order-new' }); +} +debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ +const __VLS_ctx = {}; +let __VLS_components; +let __VLS_directives; +// CSS variable injection +// CSS variable injection end +__VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({}); +if (__VLS_ctx.loading) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); +} +else if (__VLS_ctx.error) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ + ...{ class: "error" }, + }); + (__VLS_ctx.error); +} +else { + __VLS_asFunctionalElement(__VLS_intrinsicElements.table, __VLS_intrinsicElements.table)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.thead, __VLS_intrinsicElements.thead)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.tbody, __VLS_intrinsicElements.tbody)({}); + for (const [c] of __VLS_getVForSourceType((__VLS_ctx.customers))) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({ + key: (c.id), + }); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + (c.fullName); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + (c.mobile); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + (c.goldDebtGrams); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + (c.fiatDebtRial); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + (c.goldCreditLimitGrams); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ + ...{ onClick: (...[$event]) => { + if (!!(__VLS_ctx.loading)) + return; + if (!!(__VLS_ctx.error)) + return; + __VLS_ctx.goToNewOrder(c.id); + } }, + }); + } +} +/** @type {__VLS_StyleScopedClasses['error']} */ ; +var __VLS_dollars; +const __VLS_self = (await import('vue')).defineComponent({ + setup() { + return { + customers: customers, + loading: loading, + error: error, + goToNewOrder: goToNewOrder, + }; + }, +}); +export default (await import('vue')).defineComponent({ + setup() { + return {}; + }, +}); +; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/src/views/OrderDetailView.vue b/apps/admin-panel/src/views/OrderDetailView.vue new file mode 100644 index 0000000..c01d479 --- /dev/null +++ b/apps/admin-panel/src/views/OrderDetailView.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/apps/admin-panel/src/views/OrderDetailView.vue.d.ts b/apps/admin-panel/src/views/OrderDetailView.vue.d.ts new file mode 100644 index 0000000..7711b0c --- /dev/null +++ b/apps/admin-panel/src/views/OrderDetailView.vue.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; +export default _default; diff --git a/apps/admin-panel/src/views/OrderDetailView.vue.js b/apps/admin-panel/src/views/OrderDetailView.vue.js new file mode 100644 index 0000000..d6ab307 --- /dev/null +++ b/apps/admin-panel/src/views/OrderDetailView.vue.js @@ -0,0 +1,98 @@ +/// +import { onMounted, ref } from 'vue'; +import { useRoute } from 'vue-router'; +import { api } from '../api/client'; +const route = useRoute(); +const order = ref(null); +const error = ref(null); +const STATUS_FLOW = [ + 'PLACED', + 'ADMIN_APPROVED', + 'SOURCING', + 'PREPARING', + 'DELIVERED_TO_AGENT', + 'DELIVERED_TO_CUSTOMER', +]; +async function load() { + order.value = await api.getOrder(route.params.id); +} +async function advance() { + if (!order.value) + return; + const currentIndex = STATUS_FLOW.indexOf(order.value.status); + const next = STATUS_FLOW[currentIndex + 1]; + if (!next) + return; + try { + order.value = await api.updateOrderStatus(order.value.id, next); + } + catch (e) { + error.value = e.message; + } +} +onMounted(load); +debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ +const __VLS_ctx = {}; +let __VLS_components; +let __VLS_directives; +// CSS variable injection +// CSS variable injection end +if (__VLS_ctx.order) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({}); + (__VLS_ctx.order.orderNumber); + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.strong, __VLS_intrinsicElements.strong)({}); + (__VLS_ctx.order.status); + if (__VLS_ctx.order.isSuspendedOverCreditLimit) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ + ...{ class: "error" }, + }); + } + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); + (__VLS_ctx.order.totalGoldWeightGrams); + (__VLS_ctx.order.wageWeightGrams); + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); + (__VLS_ctx.order.flatRialCharge); + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); + (__VLS_ctx.order.deliveredAt ?? '—'); + if (__VLS_ctx.STATUS_FLOW.indexOf(__VLS_ctx.order.status) < __VLS_ctx.STATUS_FLOW.length - 1) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ + ...{ onClick: (__VLS_ctx.advance) }, + }); + } + if (__VLS_ctx.error) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ + ...{ class: "error" }, + }); + (__VLS_ctx.error); + } + __VLS_asFunctionalElement(__VLS_intrinsicElements.h2, __VLS_intrinsicElements.h2)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.ul, __VLS_intrinsicElements.ul)({}); + for (const [e, i] of __VLS_getVForSourceType((__VLS_ctx.order.statusEvents))) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.li, __VLS_intrinsicElements.li)({ + key: (i), + }); + (e.toStatus); + (e.createdAt); + } +} +/** @type {__VLS_StyleScopedClasses['error']} */ ; +/** @type {__VLS_StyleScopedClasses['error']} */ ; +var __VLS_dollars; +const __VLS_self = (await import('vue')).defineComponent({ + setup() { + return { + order: order, + error: error, + STATUS_FLOW: STATUS_FLOW, + advance: advance, + }; + }, +}); +export default (await import('vue')).defineComponent({ + setup() { + return {}; + }, +}); +; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/src/views/OrderNewView.vue b/apps/admin-panel/src/views/OrderNewView.vue new file mode 100644 index 0000000..2def9bf --- /dev/null +++ b/apps/admin-panel/src/views/OrderNewView.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/apps/admin-panel/src/views/OrderNewView.vue.d.ts b/apps/admin-panel/src/views/OrderNewView.vue.d.ts new file mode 100644 index 0000000..7711b0c --- /dev/null +++ b/apps/admin-panel/src/views/OrderNewView.vue.d.ts @@ -0,0 +1,2 @@ +declare const _default: import("vue").DefineComponent<{}, {}, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {}, string, import("vue").PublicProps, Readonly<{}> & Readonly<{}>, {}, {}, {}, {}, string, import("vue").ComponentProvideOptions, true, {}, any>; +export default _default; diff --git a/apps/admin-panel/src/views/OrderNewView.vue.js b/apps/admin-panel/src/views/OrderNewView.vue.js new file mode 100644 index 0000000..9220806 --- /dev/null +++ b/apps/admin-panel/src/views/OrderNewView.vue.js @@ -0,0 +1,163 @@ +/// +import { onMounted, reactive, ref } from 'vue'; +import { useRouter } from 'vue-router'; +import { api } from '../api/client'; +import { useSessionStore } from '../stores/session'; +const session = useSessionStore(); +const router = useRouter(); +const products = ref([]); +const items = reactive([ + { productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 }, +]); +const submitting = ref(false); +const error = ref(null); +onMounted(async () => { + products.value = await api.listProducts(); +}); +function addItem() { + items.push({ productVariantId: '', quantity: 1, wagePercent: 7, discountPercent: 0, flatRialCharge: 0 }); +} +function removeItem(index) { + items.splice(index, 1); +} +async function submit() { + if (!session.selectedCustomerId) { + error.value = 'Select a customer from the dashboard first.'; + return; + } + submitting.value = true; + error.value = null; + try { + const order = await api.createOrder({ + customerId: session.selectedCustomerId, + items: items.map((i) => ({ ...i })), + }); + router.push({ name: 'order-detail', params: { id: order.id } }); + } + catch (e) { + error.value = e.message; + } + finally { + submitting.value = false; + } +} +debugger; /* PartiallyEnd: #3632/scriptSetup.vue */ +const __VLS_ctx = {}; +let __VLS_components; +let __VLS_directives; +// CSS variable injection +// CSS variable injection end +__VLS_asFunctionalElement(__VLS_intrinsicElements.section, __VLS_intrinsicElements.section)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.h1, __VLS_intrinsicElements.h1)({}); +if (!__VLS_ctx.session.selectedCustomerId) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ + ...{ class: "error" }, + }); +} +else { + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({}); + (__VLS_ctx.session.selectedCustomerId); +} +__VLS_asFunctionalElement(__VLS_intrinsicElements.table, __VLS_intrinsicElements.table)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.thead, __VLS_intrinsicElements.thead)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.th, __VLS_intrinsicElements.th)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.tbody, __VLS_intrinsicElements.tbody)({}); +for (const [item, idx] of __VLS_getVForSourceType((__VLS_ctx.items))) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.tr, __VLS_intrinsicElements.tr)({ + key: (idx), + }); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.select, __VLS_intrinsicElements.select)({ + value: (item.productVariantId), + }); + __VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({ + value: "", + disabled: true, + }); + for (const [p] of __VLS_getVForSourceType((__VLS_ctx.products))) { + (p.id); + for (const [v] of __VLS_getVForSourceType((p.variants))) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.option, __VLS_intrinsicElements.option)({ + key: (v.id), + value: (v.id), + }); + (p.title); + (v.title); + (v.rashWeightGrams); + } + } + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ + type: "number", + min: "1", + }); + (item.quantity); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ + type: "number", + step: "0.1", + }); + (item.wagePercent); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ + type: "number", + step: "0.1", + }); + (item.discountPercent); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.input)({ + type: "number", + step: "1000", + }); + (item.flatRialCharge); + __VLS_asFunctionalElement(__VLS_intrinsicElements.td, __VLS_intrinsicElements.td)({}); + __VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ + ...{ onClick: (...[$event]) => { + __VLS_ctx.removeItem(idx); + } }, + }); +} +__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ + ...{ onClick: (__VLS_ctx.addItem) }, +}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.br)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.br)({}); +__VLS_asFunctionalElement(__VLS_intrinsicElements.button, __VLS_intrinsicElements.button)({ + ...{ onClick: (__VLS_ctx.submit) }, + disabled: (__VLS_ctx.submitting), +}); +if (__VLS_ctx.error) { + __VLS_asFunctionalElement(__VLS_intrinsicElements.p, __VLS_intrinsicElements.p)({ + ...{ class: "error" }, + }); + (__VLS_ctx.error); +} +/** @type {__VLS_StyleScopedClasses['error']} */ ; +/** @type {__VLS_StyleScopedClasses['error']} */ ; +var __VLS_dollars; +const __VLS_self = (await import('vue')).defineComponent({ + setup() { + return { + session: session, + products: products, + items: items, + submitting: submitting, + error: error, + addItem: addItem, + removeItem: removeItem, + submit: submit, + }; + }, +}); +export default (await import('vue')).defineComponent({ + setup() { + return {}; + }, +}); +; /* PartiallyEnd: #4569/main.vue */ diff --git a/apps/admin-panel/tsconfig.json b/apps/admin-panel/tsconfig.json new file mode 100644 index 0000000..2f9ef65 --- /dev/null +++ b/apps/admin-panel/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "preserve", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "noEmit": true, + "declaration": false + }, + "include": ["src"] +} diff --git a/apps/admin-panel/tsconfig.tsbuildinfo b/apps/admin-panel/tsconfig.tsbuildinfo new file mode 100644 index 0000000..a147f8d --- /dev/null +++ b/apps/admin-panel/tsconfig.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.vue","./src/app.vue.d.ts","./src/env.d.ts","./src/main.ts","./src/api/client.ts","./src/router/index.ts","./src/stores/session.ts","./src/views/dashboardview.vue","./src/views/dashboardview.vue.d.ts","./src/views/orderdetailview.vue","./src/views/orderdetailview.vue.d.ts","./src/views/ordernewview.vue","./src/views/ordernewview.vue.d.ts"],"version":"5.9.3"} \ No newline at end of file diff --git a/apps/admin-panel/vite.config.ts b/apps/admin-panel/vite.config.ts new file mode 100644 index 0000000..c269369 --- /dev/null +++ b/apps/admin-panel/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + +export default defineConfig({ + plugins: [vue()], + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:3000', + }, + }, +}); diff --git a/apps/api/.env.example b/apps/api/.env.example new file mode 100644 index 0000000..2103d03 --- /dev/null +++ b/apps/api/.env.example @@ -0,0 +1,2 @@ +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/gold_bank?schema=public" +PORT=3000 diff --git a/apps/api/nest-cli.json b/apps/api/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/apps/api/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/apps/api/package.json b/apps/api/package.json new file mode 100644 index 0000000..e4bb585 --- /dev/null +++ b/apps/api/package.json @@ -0,0 +1,37 @@ +{ + "name": "api", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "nest start --watch", + "build": "nest build", + "lint": "eslint \"src/**/*.ts\"", + "test": "jest", + "prisma:push": "prisma db push", + "prisma:migrate": "prisma migrate dev", + "prisma:seed": "prisma db seed", + "prisma:studio": "prisma studio" + }, + "prisma": { + "seed": "ts-node --transpile-only prisma/seed.ts" + }, + "dependencies": { + "@gold-bank/shared-types": "workspace:*", + "@nestjs/common": "^10.4.0", + "@nestjs/core": "^10.4.0", + "@nestjs/platform-express": "^10.4.0", + "@prisma/client": "^5.19.0", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.1", + "reflect-metadata": "^0.2.2", + "rxjs": "^7.8.1" + }, + "devDependencies": { + "@nestjs/cli": "^10.4.5", + "@types/express": "^4.17.21", + "@types/node": "^20.14.0", + "prisma": "^5.19.0", + "ts-node": "^10.9.2", + "typescript": "^5.5.0" + } +} diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma new file mode 100644 index 0000000..0ad65e0 --- /dev/null +++ b/apps/api/prisma/schema.prisma @@ -0,0 +1,423 @@ +// Gold Banking System — core schema +// Canonical accounting model: legacy "System A" gram ledger (see migration notes). +// The external "Tahesab" service is retired — LedgerEntry is the sole source of truth. +// All money/weight fields use Decimal, never Float, to avoid rounding drift in debt math. + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ───────────────────────────────────────────────────────────────────────── +// Enums +// ───────────────────────────────────────────────────────────────────────── + +enum AdminRole { + SUPER_ADMIN + SYSTEM_APPROVER // legacy: تایید مدیر سیستم + SALES_EXPERT // legacy Customer.type=1, also acts as a referral "reagent" + WORKSHOP_SUPERVISOR + ACCOUNTANT +} + +enum VerificationStep { + UNVERIFIED + STEP_ONE_SUBMITTED + STEP_TWO_SUBMITTED + VERIFIED +} + +enum OrderStatus { + SUSPENDED // credit limit (goldCreditLimitGrams) exceeded at placement + PLACED + ADMIN_APPROVED + SOURCING + PREPARING + DELIVERED_TO_AGENT + DELIVERED_TO_CUSTOMER // debt recognition point — mirrors legacy sts>4 + CANCELLED +} + +enum PreOrderStatus { + PLACED + ADMIN_APPROVED + WORKSHOP_APPROVED + READY_TO_SHIP + DELIVERED + CANCELLED +} + +enum LedgerCurrency { + GOLD_GRAM + RIAL +} + +enum LedgerDirection { + DEBIT // increases what the customer owes + CREDIT // decreases what the customer owes +} + +enum LedgerSourceType { + ORDER_STOCK + ORDER_PRE_ORDER + REPAYMENT_MELTED_GOLD // karat-normalized: amount * karatPerMille / 750 + REPAYMENT_TRANSFER + REPAYMENT_CHEQUE + REPAYMENT_CASH // price-per-gram-normalized via pricePerGramRialAtEntry + WALLET_BONUS + ADJUSTMENT +} + +// ───────────────────────────────────────────────────────────────────────── +// Identity +// ───────────────────────────────────────────────────────────────────────── + +model City { + id String @id @default(cuid()) + name String + province String? + customers Customer[] + addresses CustomerAddress[] + branches Branch[] + preOrders PreOrder[] +} + +model AdminUser { + id String @id @default(cuid()) + fullName String + mobile String @unique + passwordHash String + role AdminRole + branchId String? + branch Branch? @relation(fields: [branchId], references: [id]) + wagePercent Decimal? @db.Decimal(6, 3) // legacy Customer.percent, commission on referred orders + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + referredCustomers Customer[] @relation("ReferralAgent") + postedLedgerEntries LedgerEntry[] + orderStatusEvents OrderStatusEvent[] + preOrderStatusEvents PreOrderStatusEvent[] + + @@index([role]) +} + +model Customer { + id String @id @default(cuid()) + fullName String + mobile String @unique + nationalCode String @unique + passwordHash String + cityId String? + city City? @relation(fields: [cityId], references: [id]) + + referredByAgentId String? + referredByAgent AdminUser? @relation("ReferralAgent", fields: [referredByAgentId], references: [id]) + + // Credit limit + cached ledger balances (source of truth is LedgerEntry; these + // are denormalized for fast reads and reconciled by a scheduled job). + goldCreditLimitGrams Decimal @default(0) @db.Decimal(14, 4) // legacy gram_level + goldDebtGrams Decimal @default(0) @db.Decimal(14, 4) // legacy remain + fiatDebtRial Decimal @default(0) @db.Decimal(20, 2) // legacy rial_remain + + verificationStep VerificationStep @default(UNVERIFIED) + nationalIdImageUrl String? + businessLicenseUrl String? + shopImageUrl String? + + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + addresses CustomerAddress[] + orders Order[] + preOrders PreOrder[] + ledgerEntries LedgerEntry[] + walletBonuses WalletBonus[] + + @@index([referredByAgentId]) +} + +model CustomerAddress { + id String @id @default(cuid()) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + cityId String? + city City? @relation(fields: [cityId], references: [id]) + addressLine String + isDefault Boolean @default(false) +} + +model Branch { + id String @id @default(cuid()) + name String + cityId String? + city City? @relation(fields: [cityId], references: [id]) + adminUsers AdminUser[] + orders Order[] +} + +model Workshop { + id String @id @default(cuid()) + name String + isActive Boolean @default(true) + preOrders PreOrder[] +} + +// ───────────────────────────────────────────────────────────────────────── +// Catalog — item reference catalog +// ───────────────────────────────────────────────────────────────────────── + +model Category { + id String @id @default(cuid()) + title String + parentId String? + parent Category? @relation("CategoryTree", fields: [parentId], references: [id]) + children Category[] @relation("CategoryTree") + products Product[] +} + +model Brand { + id String @id @default(cuid()) + title String + products Product[] +} + +/// Curated product groupings — replaces legacy "Ciny/CinyProduct" (a tagging +/// feature, NOT a gold-price snapshot mechanism, despite the misleading legacy name). +model Collection { + id String @id @default(cuid()) + title String + description String? + isActive Boolean @default(true) + products CollectionProduct[] +} + +model CollectionProduct { + collectionId String + collection Collection @relation(fields: [collectionId], references: [id]) + productId String + product Product @relation(fields: [productId], references: [id]) + + @@id([collectionId, productId]) +} + +model Product { + id String @id @default(cuid()) + title String + description String? + categoryId String? + category Category? @relation(fields: [categoryId], references: [id]) + brandId String? + brand Brand? @relation(fields: [brandId], references: [id]) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + + variants ProductVariant[] + images ProductImage[] + collections CollectionProduct[] +} + +model ProductImage { + id String @id @default(cuid()) + productId String + product Product @relation(fields: [productId], references: [id]) + url String + sortOrder Int @default(0) +} + +model ProductVariant { + id String @id @default(cuid()) + productId String + product Product @relation(fields: [productId], references: [id]) + title String + sku String? @unique + /// Base/raw weight before making charge ("rash weight"). + rashWeightGrams Decimal @db.Decimal(14, 4) + /// Default making-charge percentage applied on top of rash weight. + defaultWagePercent Decimal @db.Decimal(6, 3) + isActive Boolean @default(true) + + orderItems OrderItem[] +} + +// ───────────────────────────────────────────────────────────────────────── +// Orders — stock orders (debt-bearing) +// ───────────────────────────────────────────────────────────────────────── + +model Order { + id String @id @default(cuid()) + orderNumber String @unique + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + branchId String? + branch Branch? @relation(fields: [branchId], references: [id]) + status OrderStatus @default(PLACED) + + /// Total gold weight incl. wage across all items ("allGram" / order.w). + totalGoldWeightGrams Decimal @db.Decimal(14, 4) + /// Pure making-charge weight component (totalGoldWeightGrams minus raw weight sum). + wageWeightGrams Decimal @db.Decimal(14, 4) + /// Flat Rial charge independent of gold price (e.g. stones) — legacy rial_input. + flatRialCharge Decimal @default(0) @db.Decimal(20, 2) + + isSuspendedOverCreditLimit Boolean @default(false) + deliveredAt DateTime? // setting this is what triggers debt recognition + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + items OrderItem[] + statusEvents OrderStatusEvent[] + ledgerEntries LedgerEntry[] + convertedFromPreOrder PreOrder? + + @@index([customerId]) + @@index([status]) +} + +model OrderItem { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id]) + productVariantId String + productVariant ProductVariant @relation(fields: [productVariantId], references: [id]) + quantity Int + + // Snapshots at order time — never re-read from ProductVariant after placement. + rashWeightGramsSnapshot Decimal @db.Decimal(14, 4) + wagePercentSnapshot Decimal @db.Decimal(6, 3) + discountPercentSnapshot Decimal @default(0) @db.Decimal(6, 3) + /// computed: rash*(1 + (wagePercent-discount)/100) + computedWeightGrams Decimal @db.Decimal(14, 4) + flatRialCharge Decimal @default(0) @db.Decimal(20, 2) +} + +model OrderStatusEvent { + id String @id @default(cuid()) + orderId String + order Order @relation(fields: [orderId], references: [id]) + fromStatus OrderStatus? + toStatus OrderStatus + note String? + actorAdminUserId String? + actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) + createdAt DateTime @default(now()) + + @@index([orderId]) +} + +// ───────────────────────────────────────────────────────────────────────── +// Pre-orders — custom manufacturing requests (not debt-bearing until converted) +// ───────────────────────────────────────────────────────────────────────── + +model PreOrder { + id String @id @default(cuid()) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + cityId String? + city City? @relation(fields: [cityId], references: [id]) + workshopId String? + workshop Workshop? @relation(fields: [workshopId], references: [id]) + status PreOrderStatus @default(PLACED) + + designImageUrl String? + colorName String? + sizeSpec String? + stoneColor String? + minaColor String? + estimatedWeightGrams Decimal? @db.Decimal(14, 4) + estimatedPriceRial Decimal? @db.Decimal(20, 2) + description String? + + /// Once priced, an admin converts this into a real (debt-bearing) Order. + convertedOrderId String? @unique + convertedOrder Order? @relation(fields: [convertedOrderId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + statusEvents PreOrderStatusEvent[] + + @@index([customerId]) +} + +model PreOrderStatusEvent { + id String @id @default(cuid()) + preOrderId String + preOrder PreOrder @relation(fields: [preOrderId], references: [id]) + /// Field-level diff, e.g. {"status": {"old": "PLACED", "new": "ADMIN_APPROVED"}} + changes Json + actorAdminUserId String? + actorAdminUser AdminUser? @relation(fields: [actorAdminUserId], references: [id]) + ipAddress String? + createdAt DateTime @default(now()) + + @@index([preOrderId]) +} + +// ───────────────────────────────────────────────────────────────────────── +// Accounting — the dual-currency ledger (core of the system) +// ───────────────────────────────────────────────────────────────────────── + +model LedgerEntry { + id String @id @default(cuid()) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + + currency LedgerCurrency + direction LedgerDirection + sourceType LedgerSourceType + /// Always positive; direction determines the sign applied to the customer balance. + amount Decimal @db.Decimal(20, 4) + + /// Purity of the physical instrument, for REPAYMENT_MELTED_GOLD (e.g. 750, 916). + karatPerMille Int? + /// Gold price/gram at conversion time, for REPAYMENT_CASH. + pricePerGramRialAtEntry Decimal? @db.Decimal(20, 2) + + orderId String? + order Order? @relation(fields: [orderId], references: [id]) + + /// Pending entries don't count toward the cached customer balance until confirmed. + confirmed Boolean @default(false) + confirmedAt DateTime? + postedByAdminUserId String? + postedByAdminUser AdminUser? @relation(fields: [postedByAdminUserId], references: [id]) + + note String? + createdAt DateTime @default(now()) + + @@index([customerId, currency]) + @@index([orderId]) +} + +/// Replaces legacy WebServiceGold / ConfigStatic.gold_price — timestamped price feed. +model GoldPriceQuote { + id String @id @default(cuid()) + karatPerMille Int + pricePerGramRial Decimal @db.Decimal(20, 2) + source String + quotedAt DateTime @default(now()) + + @@index([karatPerMille, quotedAt]) +} + +/// Time-limited gold cashback bonus — replaces the legacy Rooyesh\Wallet gold credit. +model WalletBonus { + id String @id @default(cuid()) + customerId String + customer Customer @relation(fields: [customerId], references: [id]) + orderId String? + goldGrams Decimal @db.Decimal(14, 4) + grantedAt DateTime @default(now()) + expiresAt DateTime + consumedAt DateTime? + isActive Boolean @default(true) + + @@index([customerId, isActive]) +} diff --git a/apps/api/prisma/seed.ts b/apps/api/prisma/seed.ts new file mode 100644 index 0000000..daf293b --- /dev/null +++ b/apps/api/prisma/seed.ts @@ -0,0 +1,441 @@ +import { AdminRole, PrismaClient, VerificationStep } from '@prisma/client'; + +const prisma = new PrismaClient(); + +// Placeholder — replace with a real bcrypt hash once the auth module lands. +const PLACEHOLDER_PASSWORD_HASH = 'placeholder-hash-not-for-production'; + +// Served from the user-app's own public/ folder. This is the same placeholder +// image the legacy app used for missing/failed product imagery (no-items.png). +// Replace these rows with real photo URLs when product photography exists. +const PLACEHOLDER_PRODUCT_IMAGE = '/img/no-items.png'; + +async function main() { + // ── Cities ──────────────────────────────────────────────────────────── + const [tehran, mashhad, isfahan, tabriz] = await Promise.all([ + prisma.city.upsert({ where: { id: 'city-tehran' }, update: {}, create: { id: 'city-tehran', name: 'تهران', province: 'تهران' } }), + prisma.city.upsert({ where: { id: 'city-mashhad' }, update: {}, create: { id: 'city-mashhad', name: 'مشهد', province: 'خراسان رضوی' } }), + prisma.city.upsert({ where: { id: 'city-isfahan' }, update: {}, create: { id: 'city-isfahan', name: 'اصفهان', province: 'اصفهان' } }), + prisma.city.upsert({ where: { id: 'city-tabriz' }, update: {}, create: { id: 'city-tabriz', name: 'تبریز', province: 'آذربایجان شرقی' } }), + ]); + + // ── Branches & workshop ────────────────────────────────────────────── + const branchTehran = await prisma.branch.upsert({ + where: { id: 'branch-tehran-bazaar' }, + update: {}, + create: { id: 'branch-tehran-bazaar', name: 'شعبه بازار بزرگ تهران', cityId: tehran.id }, + }); + const branchMashhad = await prisma.branch.upsert({ + where: { id: 'branch-mashhad-main' }, + update: {}, + create: { id: 'branch-mashhad-main', name: 'شعبه مرکزی مشهد', cityId: mashhad.id }, + }); + await prisma.workshop.upsert({ + where: { id: 'workshop-central' }, + update: {}, + create: { id: 'workshop-central', name: 'کارگاه مرکزی ساخت طلا' }, + }); + + // ── Admin users (sales agents + one super admin) ──────────────────── + await prisma.adminUser.upsert({ + where: { mobile: '09120000001' }, + update: {}, + create: { + mobile: '09120000001', + fullName: 'مدیر ارشد سیستم', + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: AdminRole.SUPER_ADMIN, + branchId: branchTehran.id, + }, + }); + const agentReza = await prisma.adminUser.upsert({ + where: { mobile: '09121234567' }, + update: {}, + create: { + mobile: '09121234567', + fullName: 'رضا احمدی', + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: AdminRole.SALES_EXPERT, + branchId: branchTehran.id, + wagePercent: 1.5, + }, + }); + const agentSara = await prisma.adminUser.upsert({ + where: { mobile: '09129876543' }, + update: {}, + create: { + mobile: '09129876543', + fullName: 'سارا کریمی', + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: AdminRole.SALES_EXPERT, + branchId: branchMashhad.id, + wagePercent: 1.2, + }, + }); + await prisma.adminUser.upsert({ + where: { mobile: '09123334444' }, + update: {}, + create: { + mobile: '09123334444', + fullName: 'هومن یزدانی', + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: AdminRole.WORKSHOP_SUPERVISOR, + }, + }); + await prisma.adminUser.upsert({ + where: { mobile: '09125556666' }, + update: {}, + create: { + mobile: '09125556666', + fullName: 'فرزانه محمودی', + passwordHash: PLACEHOLDER_PASSWORD_HASH, + role: AdminRole.ACCOUNTANT, + branchId: branchTehran.id, + }, + }); + + // ── Customers ──────────────────────────────────────────────────────── + const customerData = [ + { + id: 'customer-1', + fullName: 'علی محمدی', + mobile: '09151112233', + nationalCode: '0012345678', + cityId: tehran.id, + referredByAgentId: agentReza.id, + goldCreditLimitGrams: '200', + verificationStep: VerificationStep.VERIFIED, + }, + { + id: 'customer-2', + fullName: 'مریم حسینی', + mobile: '09151112234', + nationalCode: '0012345679', + cityId: tehran.id, + referredByAgentId: agentReza.id, + goldCreditLimitGrams: '100', + verificationStep: VerificationStep.VERIFIED, + }, + { + id: 'customer-3', + fullName: 'جواد نوروزی', + mobile: '09151112235', + nationalCode: '0012345680', + cityId: mashhad.id, + referredByAgentId: agentSara.id, + goldCreditLimitGrams: '50', + verificationStep: VerificationStep.STEP_TWO_SUBMITTED, + }, + { + id: 'customer-4', + fullName: 'نرگس ابراهیمی', + mobile: '09151112236', + nationalCode: '0012345681', + cityId: isfahan.id, + referredByAgentId: null, + goldCreditLimitGrams: '30', + verificationStep: VerificationStep.UNVERIFIED, + }, + { + id: 'customer-5', + fullName: 'کامران صادقی', + mobile: '09151112237', + nationalCode: '0012345682', + cityId: tehran.id, + referredByAgentId: agentReza.id, + goldCreditLimitGrams: '500', + verificationStep: VerificationStep.VERIFIED, + }, + { + id: 'customer-6', + fullName: 'زهرا قاسمی', + mobile: '09151112238', + nationalCode: '0012345683', + cityId: tabriz.id, + referredByAgentId: agentSara.id, + goldCreditLimitGrams: '80', + verificationStep: VerificationStep.STEP_ONE_SUBMITTED, + }, + ]; + + const customers = []; + for (const c of customerData) { + customers.push( + await prisma.customer.upsert({ + where: { id: c.id }, + update: {}, + create: { + id: c.id, + fullName: c.fullName, + mobile: c.mobile, + nationalCode: c.nationalCode, + passwordHash: PLACEHOLDER_PASSWORD_HASH, + cityId: c.cityId, + referredByAgentId: c.referredByAgentId, + goldCreditLimitGrams: c.goldCreditLimitGrams, + verificationStep: c.verificationStep, + }, + }), + ); + } + const [customer1, customer2, , , customer5] = customers; + + // ── Catalog: categories, brands, products, variants ──────────────── + const [ringsCategory, braceletsCategory, necklacesCategory, earringsCategory, coinsCategory] = await Promise.all([ + prisma.category.upsert({ where: { id: 'cat-rings' }, update: {}, create: { id: 'cat-rings', title: 'انگشتر' } }), + prisma.category.upsert({ where: { id: 'cat-bracelets' }, update: {}, create: { id: 'cat-bracelets', title: 'دستبند' } }), + prisma.category.upsert({ where: { id: 'cat-necklaces' }, update: {}, create: { id: 'cat-necklaces', title: 'گردنبند' } }), + prisma.category.upsert({ where: { id: 'cat-earrings' }, update: {}, create: { id: 'cat-earrings', title: 'گوشواره' } }), + prisma.category.upsert({ where: { id: 'cat-coins' }, update: {}, create: { id: 'cat-coins', title: 'سکه و شمش' } }), + ]); + + const brandHenza = await prisma.brand.upsert({ + where: { id: 'brand-henza' }, + update: {}, + create: { id: 'brand-henza', title: 'هنزا طلا' }, + }); + const brandArtisan = await prisma.brand.upsert({ + where: { id: 'brand-artisan' }, + update: {}, + create: { id: 'brand-artisan', title: 'مجموعه صنعتگر' }, + }); + + const productDefs = [ + { + id: 'product-classic-ring', + title: 'انگشتر تک نگین کلاسیک', + description: 'انگشتر طلای ۱۸ عیار با طراحی ساده و مینیمال.', + categoryId: ringsCategory.id, + brandId: brandHenza.id, + variants: [ + { id: 'variant-ring-size-6', title: 'سایز ۶', sku: 'RING-CLASSIC-6', rashWeightGrams: '2.850', defaultWagePercent: '9.0' }, + { id: 'variant-ring-size-7', title: 'سایز ۷', sku: 'RING-CLASSIC-7', rashWeightGrams: '3.100', defaultWagePercent: '9.0' }, + ], + }, + { + id: 'product-woven-bracelet', + title: 'دستبند بافت زنجیری', + description: 'دستبند بافت‌دار دست‌ساز از طلای ۱۸ عیار.', + categoryId: braceletsCategory.id, + brandId: brandArtisan.id, + variants: [ + { id: 'variant-bracelet-standard', title: 'استاندارد ۱۸ سانتی', sku: 'BRC-WOVEN-18', rashWeightGrams: '14.200', defaultWagePercent: '11.5' }, + ], + }, + { + id: 'product-pendant-necklace', + title: 'گردنبند آویز اشکی', + description: 'گردنبند ظریف با آویز اشکی و زنجیر نازک.', + categoryId: necklacesCategory.id, + brandId: brandHenza.id, + variants: [ + { id: 'variant-necklace-40cm', title: 'زنجیر ۴۰ سانتی', sku: 'NCK-TEARDROP-40', rashWeightGrams: '5.600', defaultWagePercent: '10.0' }, + { id: 'variant-necklace-45cm', title: 'زنجیر ۴۵ سانتی', sku: 'NCK-TEARDROP-45', rashWeightGrams: '6.100', defaultWagePercent: '10.0' }, + ], + }, + { + id: 'product-hoop-earrings', + title: 'گوشواره حلقه‌ای کلاسیک', + description: 'گوشواره حلقه‌ای طلای ۱۸ عیار مناسب استفاده روزمره.', + categoryId: earringsCategory.id, + brandId: brandArtisan.id, + variants: [ + { id: 'variant-hoop-small', title: 'کوچک', sku: 'ERG-HOOP-S', rashWeightGrams: '1.900', defaultWagePercent: '8.5' }, + { id: 'variant-hoop-large', title: 'بزرگ', sku: 'ERG-HOOP-L', rashWeightGrams: '3.400', defaultWagePercent: '8.5' }, + ], + }, + { + id: 'product-full-coin', + title: 'سکه تمام بهار آزادی', + description: 'سکه طلای تمام بهار آزادی، عیار استاندارد.', + categoryId: coinsCategory.id, + brandId: brandHenza.id, + variants: [ + { id: 'variant-coin-full', title: 'تمام سکه', sku: 'COIN-FULL', rashWeightGrams: '8.133', defaultWagePercent: '2.0' }, + { id: 'variant-coin-half', title: 'نیم سکه', sku: 'COIN-HALF', rashWeightGrams: '4.066', defaultWagePercent: '2.5' }, + ], + }, + ]; + + const createdVariantIds: Record = {}; + let imageCount = 0; + for (const p of productDefs) { + const product = await prisma.product.upsert({ + where: { id: p.id }, + update: {}, + create: { + id: p.id, + title: p.title, + description: p.description, + categoryId: p.categoryId, + brandId: p.brandId, + }, + }); + for (const v of p.variants) { + await prisma.productVariant.upsert({ + where: { id: v.id }, + update: {}, + create: { + id: v.id, + productId: product.id, + title: v.title, + sku: v.sku, + rashWeightGrams: v.rashWeightGrams, + defaultWagePercent: v.defaultWagePercent, + }, + }); + createdVariantIds[v.id] = v.id; + } + + // Product photography does not exist in this project: legacy served it from + // the old Laravel host (Product.thumb.full), so there is nothing to migrate. + // Seed the placeholder the legacy app itself used for missing imagery so + // every product card renders an image instead of collapsing to nothing. + await prisma.productImage.upsert({ + where: { id: `${p.id}-img-1` }, + update: { url: PLACEHOLDER_PRODUCT_IMAGE }, + create: { + id: `${p.id}-img-1`, + productId: product.id, + url: PLACEHOLDER_PRODUCT_IMAGE, + sortOrder: 0, + }, + }); + imageCount += 1; + } + + // ── Featured collection ────────────────────────────────────────────── + const featured = await prisma.collection.upsert({ + where: { id: 'collection-featured' }, + update: {}, + create: { id: 'collection-featured', title: 'پرفروش‌های این هفته', description: 'منتخبی از محبوب‌ترین محصولات فروشگاه.' }, + }); + await prisma.collectionProduct.upsert({ + where: { collectionId_productId: { collectionId: featured.id, productId: 'product-classic-ring' } }, + update: {}, + create: { collectionId: featured.id, productId: 'product-classic-ring' }, + }); + await prisma.collectionProduct.upsert({ + where: { collectionId_productId: { collectionId: featured.id, productId: 'product-pendant-necklace' } }, + update: {}, + create: { collectionId: featured.id, productId: 'product-pendant-necklace' }, + }); + await prisma.collectionProduct.upsert({ + where: { collectionId_productId: { collectionId: featured.id, productId: 'product-full-coin' } }, + update: {}, + create: { collectionId: featured.id, productId: 'product-full-coin' }, + }); + + // ── Gold price quote (illustrative, not live-market-accurate) ─────── + await prisma.goldPriceQuote.create({ + data: { karatPerMille: 750, pricePerGramRial: '61500000', source: 'seed-illustrative' }, + }); + + // ── A delivered order + posted debt, so the dashboard shows real numbers ── + const existingDemoOrder = await prisma.order.findUnique({ where: { orderNumber: 'ORD-SEED-0001' } }); + if (!existingDemoOrder) { + const rashWeight = '3.100'; // variant-ring-size-7 + const wagePercent = 9.0; + const discountPercent = 0; + const computedWeight = (parseFloat(rashWeight) * (1 + (wagePercent - discountPercent) / 100)).toFixed(4); + const quantity = 1; + const flatRialChargePerUnit = '500000'; // e.g. a small stone-setting fee + + const order = await prisma.order.create({ + data: { + orderNumber: 'ORD-SEED-0001', + customerId: customer1.id, + branchId: branchTehran.id, + status: 'DELIVERED_TO_CUSTOMER', + totalGoldWeightGrams: computedWeight, + wageWeightGrams: (parseFloat(computedWeight) - parseFloat(rashWeight)).toFixed(4), + flatRialCharge: flatRialChargePerUnit, + deliveredAt: new Date(), + items: { + create: [ + { + productVariantId: 'variant-ring-size-7', + quantity, + rashWeightGramsSnapshot: rashWeight, + wagePercentSnapshot: wagePercent.toString(), + discountPercentSnapshot: discountPercent.toString(), + computedWeightGrams: computedWeight, + flatRialCharge: flatRialChargePerUnit, + }, + ], + }, + statusEvents: { + create: [{ toStatus: 'PLACED' }, { toStatus: 'ADMIN_APPROVED' }, { toStatus: 'DELIVERED_TO_CUSTOMER' }], + }, + }, + }); + + await prisma.ledgerEntry.create({ + data: { + customerId: customer1.id, + currency: 'GOLD_GRAM', + direction: 'DEBIT', + sourceType: 'ORDER_STOCK', + amount: computedWeight, + orderId: order.id, + confirmed: true, + confirmedAt: new Date(), + note: 'بدهی وزنی طلا هنگام تحویل (داده آزمایشی)', + }, + }); + await prisma.ledgerEntry.create({ + data: { + customerId: customer1.id, + currency: 'RIAL', + direction: 'DEBIT', + sourceType: 'ORDER_STOCK', + amount: flatRialChargePerUnit, + orderId: order.id, + confirmed: true, + confirmedAt: new Date(), + note: 'هزینه ثابت ریالی هنگام تحویل (داده آزمایشی)', + }, + }); + await prisma.customer.update({ + where: { id: customer1.id }, + data: { goldDebtGrams: { increment: computedWeight }, fiatDebtRial: { increment: flatRialChargePerUnit } }, + }); + + // A pending repayment awaiting admin confirmation, to demo the confirm flow. + await prisma.ledgerEntry.create({ + data: { + customerId: customer1.id, + currency: 'GOLD_GRAM', + direction: 'CREDIT', + sourceType: 'REPAYMENT_MELTED_GOLD', + amount: '1.5000', + karatPerMille: 750, + confirmed: false, + note: 'تسویه با طلای آب‌شده، در انتظار تایید (داده آزمایشی)', + }, + }); + } + + // customer2 and customer5 get existing debt/credit variety for the dashboard. + await prisma.customer.update({ where: { id: customer2.id }, data: { goldDebtGrams: '8.2500', fiatDebtRial: '2000000' } }); + await prisma.customer.update({ where: { id: customer5.id }, data: { goldDebtGrams: '0', fiatDebtRial: '0' } }); + + console.log('Seed complete:', { + cities: 4, + branches: 2, + adminUsers: 5, + customers: customers.length, + products: productDefs.length, + variants: Object.keys(createdVariantIds).length, + productImages: imageCount, + }); +} + +main() + .catch((e) => { + console.error(e); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts new file mode 100644 index 0000000..1fd0712 --- /dev/null +++ b/apps/api/src/app.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { PrismaModule } from './prisma/prisma.module'; +import { CatalogModule } from './modules/catalog/catalog.module'; +import { CustomersModule } from './modules/customers/customers.module'; +import { OrdersModule } from './modules/orders/orders.module'; +import { AccountingModule } from './modules/accounting/accounting.module'; +import { PricingModule } from './modules/pricing/pricing.module'; + +@Module({ + imports: [PrismaModule, CatalogModule, CustomersModule, OrdersModule, AccountingModule, PricingModule], +}) +export class AppModule {} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts new file mode 100644 index 0000000..bd1c143 --- /dev/null +++ b/apps/api/src/main.ts @@ -0,0 +1,15 @@ +import 'reflect-metadata'; +import { NestFactory } from '@nestjs/core'; +import { ValidationPipe } from '@nestjs/common'; +import { AppModule } from './app.module'; +import { PrismaExceptionFilter } from './prisma/prisma-exception.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + app.setGlobalPrefix('api'); + app.useGlobalPipes(new ValidationPipe({ whitelist: true, transform: true })); + app.useGlobalFilters(new PrismaExceptionFilter()); + app.enableCors(); + await app.listen(process.env.PORT ?? 3000); +} +bootstrap(); diff --git a/apps/api/src/modules/accounting/accounting.controller.ts b/apps/api/src/modules/accounting/accounting.controller.ts new file mode 100644 index 0000000..90df990 --- /dev/null +++ b/apps/api/src/modules/accounting/accounting.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { LedgerService } from './ledger.service'; +import { PostGoldRepaymentDto } from './dto/post-gold-repayment.dto'; +import { PostRialRepaymentDto } from './dto/post-rial-repayment.dto'; + +@Controller('accounting') +export class AccountingController { + constructor(private readonly ledger: LedgerService) {} + + @Get('customers/:customerId/balance') + getBalance(@Param('customerId') customerId: string) { + return this.ledger.getBalance(customerId); + } + + @Post('customers/:customerId/reconcile') + reconcile(@Param('customerId') customerId: string) { + return this.ledger.reconcileBalance(customerId); + } + + @Post('repayments/gold') + postGoldRepayment(@Body() dto: PostGoldRepaymentDto) { + return this.ledger.postGoldRepayment(dto); + } + + @Post('repayments/rial') + postRialRepayment(@Body() dto: PostRialRepaymentDto) { + return this.ledger.postRialRepayment(dto); + } + + @Post('entries/:entryId/confirm') + confirmEntry(@Param('entryId') entryId: string) { + return this.ledger.confirmEntry(entryId); + } +} diff --git a/apps/api/src/modules/accounting/accounting.module.ts b/apps/api/src/modules/accounting/accounting.module.ts new file mode 100644 index 0000000..9a8cb70 --- /dev/null +++ b/apps/api/src/modules/accounting/accounting.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { LedgerService } from './ledger.service'; +import { AccountingController } from './accounting.controller'; + +@Module({ + controllers: [AccountingController], + providers: [LedgerService], + exports: [LedgerService], +}) +export class AccountingModule {} diff --git a/apps/api/src/modules/accounting/dto/post-gold-repayment.dto.ts b/apps/api/src/modules/accounting/dto/post-gold-repayment.dto.ts new file mode 100644 index 0000000..5193c22 --- /dev/null +++ b/apps/api/src/modules/accounting/dto/post-gold-repayment.dto.ts @@ -0,0 +1,26 @@ +import { IsIn, IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class PostGoldRepaymentDto { + @IsString() + customerId!: string; + + @IsIn(['MELTED_GOLD', 'TRANSFER', 'CHEQUE', 'CASH']) + instrument!: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'; + + @IsNumber() + @IsPositive() + rawValue!: number; + + @IsOptional() + @IsNumber() + karatPerMille?: number; + + @IsOptional() + @IsNumber() + @IsPositive() + pricePerGramRial?: number; + + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/api/src/modules/accounting/dto/post-rial-repayment.dto.ts b/apps/api/src/modules/accounting/dto/post-rial-repayment.dto.ts new file mode 100644 index 0000000..0b9aa49 --- /dev/null +++ b/apps/api/src/modules/accounting/dto/post-rial-repayment.dto.ts @@ -0,0 +1,14 @@ +import { IsNumber, IsOptional, IsPositive, IsString } from 'class-validator'; + +export class PostRialRepaymentDto { + @IsString() + customerId!: string; + + @IsNumber() + @IsPositive() + amount!: number; + + @IsOptional() + @IsString() + note?: string; +} diff --git a/apps/api/src/modules/accounting/ledger.service.ts b/apps/api/src/modules/accounting/ledger.service.ts new file mode 100644 index 0000000..8bf68b8 --- /dev/null +++ b/apps/api/src/modules/accounting/ledger.service.ts @@ -0,0 +1,313 @@ +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { LedgerCurrency, LedgerDirection, LedgerSourceType, Prisma, PrismaClient } from '@prisma/client'; +import { GRAMS_PER_MESGHAL, STANDARD_KARAT_PER_MILLE } from '@gold-bank/shared-types'; +import { PrismaService } from '../../prisma/prisma.service'; + +type Tx = Prisma.TransactionClient | PrismaClient; + +export interface PostEntryInput { + customerId: string; + currency: LedgerCurrency; + direction: LedgerDirection; + sourceType: LedgerSourceType; + amount: Prisma.Decimal.Value; + orderId?: string; + karatPerMille?: number; + pricePerGramRialAtEntry?: Prisma.Decimal.Value; + note?: string; + /** Order-delivery debt entries are self-confirming; repayments start pending. */ + confirmed?: boolean; + postedByAdminUserId?: string; +} + +/** + * The dual-currency accounting engine. LedgerEntry is append-only and is the + * sole source of truth for customer debt; Customer.goldDebtGrams/fiatDebtRial + * are denormalized caches kept in sync here and self-healed by reconcileBalance. + */ +@Injectable() +export class LedgerService { + constructor(private readonly prisma: PrismaService) {} + + /** Posts a ledger entry and, if confirmed, applies it to the cached customer balance. */ + async postEntry(input: PostEntryInput, tx: Tx = this.prisma) { + const amount = new Prisma.Decimal(input.amount); + if (amount.lte(0)) { + throw new BadRequestException('Ledger entry amount must be positive'); + } + const confirmed = input.confirmed ?? false; + + const entry = await tx.ledgerEntry.create({ + data: { + customerId: input.customerId, + currency: input.currency, + direction: input.direction, + sourceType: input.sourceType, + amount, + orderId: input.orderId, + karatPerMille: input.karatPerMille, + pricePerGramRialAtEntry: + input.pricePerGramRialAtEntry !== undefined ? new Prisma.Decimal(input.pricePerGramRialAtEntry) : undefined, + note: input.note, + confirmed, + confirmedAt: confirmed ? new Date() : null, + postedByAdminUserId: input.postedByAdminUserId, + }, + }); + + if (confirmed) { + await this.applyToBalance(input.customerId, input.currency, input.direction, amount, tx); + } + + return entry; + } + + /** + * Marks a pending repayment entry confirmed. Legacy's confirmed toggle + * (Adm\CashController::toggleConfiremCash) is bidirectional and used for + * post-hoc correction/rejection — legacy Cash rows are auto-confirmed + * (confirmed=1) at creation, not created pending. This system intentionally + * requires an explicit confirmation step before a repayment counts (see + * postGoldRepayment for the full rationale) — that is a deliberate design + * change from legacy, not a faithful mirror of it. + */ + async confirmEntry(entryId: string, adminUserId?: string, tx: Tx = this.prisma) { + const entry = await tx.ledgerEntry.findUnique({ where: { id: entryId } }); + if (!entry) throw new NotFoundException('Ledger entry not found'); + if (entry.confirmed) return entry; + + const updated = await tx.ledgerEntry.update({ + where: { id: entryId }, + data: { confirmed: true, confirmedAt: new Date(), postedByAdminUserId: adminUserId ?? entry.postedByAdminUserId }, + }); + + await this.applyToBalance(entry.customerId, entry.currency, entry.direction, entry.amount, tx); + return updated; + } + + private async applyToBalance( + customerId: string, + currency: LedgerCurrency, + direction: LedgerDirection, + amount: Prisma.Decimal, + tx: Tx, + ) { + const signedDelta = direction === LedgerDirection.DEBIT ? amount : amount.neg(); + await tx.customer.update({ + where: { id: customerId }, + data: + currency === LedgerCurrency.GOLD_GRAM + ? { goldDebtGrams: { increment: signedDelta } } + : { fiatDebtRial: { increment: signedDelta } }, + }); + } + + /** + * Posts the dual-currency debt for a stock order once it reaches + * DELIVERED_TO_AGENT: GOLD_GRAM debt = order.totalGoldWeightGrams, RIAL debt + * = order.flatRialCharge. Both entries are self-confirming — the status + * transition itself is the confirming event (mirrors legacy sts>4, which + * first becomes true at sts=5 "تحویل مسئول فروش" / delivered to agent, not + * sts=6 delivered to customer). + */ + async postOrderDebt(orderId: string, tx: Tx = this.prisma) { + const order = await tx.order.findUnique({ where: { id: orderId } }); + if (!order) throw new NotFoundException('Order not found'); + + await this.postEntry( + { + customerId: order.customerId, + currency: LedgerCurrency.GOLD_GRAM, + direction: LedgerDirection.DEBIT, + sourceType: LedgerSourceType.ORDER_STOCK, + amount: order.totalGoldWeightGrams, + orderId: order.id, + confirmed: true, + note: 'Gold-weight debt on delivery', + }, + tx, + ); + + if (new Prisma.Decimal(order.flatRialCharge).gt(0)) { + await this.postEntry( + { + customerId: order.customerId, + currency: LedgerCurrency.RIAL, + direction: LedgerDirection.DEBIT, + sourceType: LedgerSourceType.ORDER_STOCK, + amount: order.flatRialCharge, + orderId: order.id, + confirmed: true, + note: 'Flat Rial charge (e.g. stones) on delivery', + }, + tx, + ); + } + } + + /** + * Normalizes a physical repayment instrument into gold grams and posts a + * pending (unconfirmed) CREDIT entry against the customer's gold debt. + * Formulas preserved verbatim from legacy CashController::store. Note: + * legacy auto-confirms (confirmed=1) every Cash row at creation — posting + * this as pending instead is an intentional design change, not a mirror of + * legacy's default. + */ + async postGoldRepayment( + params: { + customerId: string; + instrument: 'MELTED_GOLD' | 'TRANSFER' | 'CHEQUE' | 'CASH'; + rawValue: Prisma.Decimal.Value; + karatPerMille?: number; + pricePerGramRial?: Prisma.Decimal.Value; + note?: string; + postedByAdminUserId?: string; + }, + tx: Tx = this.prisma, + ) { + const raw = new Prisma.Decimal(params.rawValue); + let goldGramsValue: Prisma.Decimal; + let sourceType: LedgerSourceType; + + switch (params.instrument) { + case 'MELTED_GOLD': { + if (!params.karatPerMille) { + throw new BadRequestException('karatPerMille is required for a melted-gold repayment'); + } + // legacy: value = original_value * ayar / 750 + goldGramsValue = raw.mul(params.karatPerMille).div(STANDARD_KARAT_PER_MILLE); + sourceType = LedgerSourceType.REPAYMENT_MELTED_GOLD; + break; + } + case 'CASH': { + if (!params.pricePerGramRial) { + throw new BadRequestException('pricePerGramRial is required for a cash repayment'); + } + // legacy: value = price / (price_per_gram / 4.331802) — preserved verbatim; + // flagged in migration analysis as a formula worth stakeholder review. + const pricePerGram = new Prisma.Decimal(params.pricePerGramRial); + goldGramsValue = raw.div(pricePerGram.div(GRAMS_PER_MESGHAL)); + sourceType = LedgerSourceType.REPAYMENT_CASH; + break; + } + case 'TRANSFER': + goldGramsValue = raw; + sourceType = LedgerSourceType.REPAYMENT_TRANSFER; + break; + case 'CHEQUE': + goldGramsValue = raw; + sourceType = LedgerSourceType.REPAYMENT_CHEQUE; + break; + } + + return this.postEntry( + { + customerId: params.customerId, + currency: LedgerCurrency.GOLD_GRAM, + direction: LedgerDirection.CREDIT, + sourceType, + amount: goldGramsValue, + karatPerMille: params.karatPerMille, + pricePerGramRialAtEntry: params.pricePerGramRial, + note: params.note, + confirmed: false, // pending until an admin confirms — intentional deviation from legacy, which auto-confirms on creation + postedByAdminUserId: params.postedByAdminUserId, + }, + tx, + ); + } + + /** + * Direct Rial-debt repayment. Not present in the legacy system (its + * rial_remain had no repayment offset at all — a gap flagged during + * migration analysis) but supported here since the new ledger is symmetric. + */ + async postRialRepayment( + params: { customerId: string; amount: Prisma.Decimal.Value; note?: string; postedByAdminUserId?: string }, + tx: Tx = this.prisma, + ) { + return this.postEntry( + { + customerId: params.customerId, + currency: LedgerCurrency.RIAL, + direction: LedgerDirection.CREDIT, + sourceType: LedgerSourceType.REPAYMENT_CASH, + amount: params.amount, + note: params.note, + confirmed: false, + postedByAdminUserId: params.postedByAdminUserId, + }, + tx, + ); + } + + async postAdjustment( + params: { + customerId: string; + currency: LedgerCurrency; + direction: LedgerDirection; + amount: Prisma.Decimal.Value; + note: string; + postedByAdminUserId: string; + }, + tx: Tx = this.prisma, + ) { + return this.postEntry( + { + customerId: params.customerId, + currency: params.currency, + direction: params.direction, + sourceType: LedgerSourceType.ADJUSTMENT, + amount: params.amount, + note: params.note, + confirmed: true, + postedByAdminUserId: params.postedByAdminUserId, + }, + tx, + ); + } + + async getBalance(customerId: string) { + const customer = await this.prisma.customer.findUnique({ + where: { id: customerId }, + select: { id: true, goldDebtGrams: true, fiatDebtRial: true, goldCreditLimitGrams: true }, + }); + if (!customer) throw new NotFoundException('Customer not found'); + return { + customerId: customer.id, + goldDebtGrams: customer.goldDebtGrams, + fiatDebtRial: customer.fiatDebtRial, + goldCreditLimitGrams: customer.goldCreditLimitGrams, + }; + } + + /** + * Recomputes cached balances from confirmed LedgerEntry rows and overwrites + * the cache — a self-healing reconciliation, run periodically or on demand, + * unlike legacy's recompute-on-every-write (Customer::updateRemain). + */ + async reconcileBalance(customerId: string) { + const [goldDebits, goldCredits, rialDebits, rialCredits] = await Promise.all([ + this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.DEBIT), + this.sumConfirmed(customerId, LedgerCurrency.GOLD_GRAM, LedgerDirection.CREDIT), + this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.DEBIT), + this.sumConfirmed(customerId, LedgerCurrency.RIAL, LedgerDirection.CREDIT), + ]); + + return this.prisma.customer.update({ + where: { id: customerId }, + data: { + goldDebtGrams: goldDebits.sub(goldCredits), + fiatDebtRial: rialDebits.sub(rialCredits), + }, + }); + } + + private async sumConfirmed(customerId: string, currency: LedgerCurrency, direction: LedgerDirection) { + const result = await this.prisma.ledgerEntry.aggregate({ + where: { customerId, currency, direction, confirmed: true }, + _sum: { amount: true }, + }); + return result._sum.amount ?? new Prisma.Decimal(0); + } +} diff --git a/apps/api/src/modules/catalog/catalog.controller.ts b/apps/api/src/modules/catalog/catalog.controller.ts new file mode 100644 index 0000000..a7ead7c --- /dev/null +++ b/apps/api/src/modules/catalog/catalog.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get } from '@nestjs/common'; +import { CatalogService } from './catalog.service'; + +@Controller('catalog/products') +export class CatalogController { + constructor(private readonly catalog: CatalogService) {} + + @Get() + list() { + return this.catalog.listProducts(); + } +} diff --git a/apps/api/src/modules/catalog/catalog.module.ts b/apps/api/src/modules/catalog/catalog.module.ts new file mode 100644 index 0000000..8f48122 --- /dev/null +++ b/apps/api/src/modules/catalog/catalog.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CatalogService } from './catalog.service'; +import { CatalogController } from './catalog.controller'; + +@Module({ + controllers: [CatalogController], + providers: [CatalogService], + exports: [CatalogService], +}) +export class CatalogModule {} diff --git a/apps/api/src/modules/catalog/catalog.service.ts b/apps/api/src/modules/catalog/catalog.service.ts new file mode 100644 index 0000000..ec0e471 --- /dev/null +++ b/apps/api/src/modules/catalog/catalog.service.ts @@ -0,0 +1,28 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class CatalogService { + constructor(private readonly prisma: PrismaService) {} + + listProducts() { + return this.prisma.product.findMany({ + where: { isActive: true }, + include: { + variants: true, + images: true, + category: true, + brand: true, + collections: { include: { collection: true } }, + }, + }); + } + + async getVariantOrThrow(variantId: string) { + const variant = await this.prisma.productVariant.findUnique({ where: { id: variantId } }); + if (!variant || !variant.isActive) { + throw new NotFoundException(`Product variant ${variantId} not found or inactive`); + } + return variant; + } +} diff --git a/apps/api/src/modules/customers/customers.controller.ts b/apps/api/src/modules/customers/customers.controller.ts new file mode 100644 index 0000000..cbd9a7e --- /dev/null +++ b/apps/api/src/modules/customers/customers.controller.ts @@ -0,0 +1,27 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { CustomersService } from './customers.service'; + +@Controller('customers') +export class CustomersController { + constructor(private readonly customers: CustomersService) {} + + @Get() + list() { + return this.customers.list(); + } + + @Get(':id') + get(@Param('id') id: string) { + return this.customers.getOrThrow(id); + } + + @Get(':id/orders') + getOrders(@Param('id') id: string) { + return this.customers.getOrders(id); + } + + @Get(':id/ledger-entries') + getLedgerEntries(@Param('id') id: string) { + return this.customers.getLedgerEntries(id); + } +} diff --git a/apps/api/src/modules/customers/customers.module.ts b/apps/api/src/modules/customers/customers.module.ts new file mode 100644 index 0000000..febd1f8 --- /dev/null +++ b/apps/api/src/modules/customers/customers.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { CustomersService } from './customers.service'; +import { CustomersController } from './customers.controller'; + +@Module({ + controllers: [CustomersController], + providers: [CustomersService], + exports: [CustomersService], +}) +export class CustomersModule {} diff --git a/apps/api/src/modules/customers/customers.service.ts b/apps/api/src/modules/customers/customers.service.ts new file mode 100644 index 0000000..a884db1 --- /dev/null +++ b/apps/api/src/modules/customers/customers.service.ts @@ -0,0 +1,59 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +// passwordHash must never leave the API — every customer read goes through this. +const PUBLIC_CUSTOMER_SELECT = { + id: true, + fullName: true, + mobile: true, + nationalCode: true, + cityId: true, + referredByAgentId: true, + goldCreditLimitGrams: true, + goldDebtGrams: true, + fiatDebtRial: true, + verificationStep: true, + nationalIdImageUrl: true, + businessLicenseUrl: true, + shopImageUrl: true, + isActive: true, + createdAt: true, + updatedAt: true, +} satisfies Prisma.CustomerSelect; + +@Injectable() +export class CustomersService { + constructor(private readonly prisma: PrismaService) {} + + async getOrThrow(customerId: string) { + const customer = await this.prisma.customer.findUnique({ + where: { id: customerId }, + select: PUBLIC_CUSTOMER_SELECT, + }); + if (!customer) throw new NotFoundException(`Customer ${customerId} not found`); + return customer; + } + + list() { + return this.prisma.customer.findMany({ + select: PUBLIC_CUSTOMER_SELECT, + orderBy: { createdAt: 'desc' }, + }); + } + + getOrders(customerId: string) { + return this.prisma.order.findMany({ + where: { customerId }, + include: { items: true }, + orderBy: { createdAt: 'desc' }, + }); + } + + getLedgerEntries(customerId: string) { + return this.prisma.ledgerEntry.findMany({ + where: { customerId }, + orderBy: { createdAt: 'desc' }, + }); + } +} diff --git a/apps/api/src/modules/orders/dto/create-order.dto.ts b/apps/api/src/modules/orders/dto/create-order.dto.ts new file mode 100644 index 0000000..d12800f --- /dev/null +++ b/apps/api/src/modules/orders/dto/create-order.dto.ts @@ -0,0 +1,39 @@ +import { Type } from 'class-transformer'; +import { ArrayMinSize, IsArray, IsInt, IsNumber, IsOptional, IsPositive, IsString, Min, ValidateNested } from 'class-validator'; + +export class CreateOrderItemInputDto { + @IsString() + productVariantId!: string; + + @IsInt() + @Min(1) + quantity!: number; + + /** Making-charge percentage negotiated for this order (may differ from the variant default). */ + @IsNumber() + wagePercent!: number; + + @IsOptional() + @IsNumber() + discountPercent?: number; + + /** Flat Rial charge per unit, independent of gold weight (e.g. stones). */ + @IsOptional() + @IsNumber() + flatRialCharge?: number; +} + +export class CreateOrderDto { + @IsString() + customerId!: string; + + @IsOptional() + @IsString() + branchId?: string; + + @IsArray() + @ArrayMinSize(1) + @ValidateNested({ each: true }) + @Type(() => CreateOrderItemInputDto) + items!: CreateOrderItemInputDto[]; +} diff --git a/apps/api/src/modules/orders/dto/update-order-status.dto.ts b/apps/api/src/modules/orders/dto/update-order-status.dto.ts new file mode 100644 index 0000000..5f6a2c0 --- /dev/null +++ b/apps/api/src/modules/orders/dto/update-order-status.dto.ts @@ -0,0 +1,15 @@ +import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { OrderStatus } from '@prisma/client'; + +export class UpdateOrderStatusDto { + @IsEnum(OrderStatus) + status!: OrderStatus; + + @IsOptional() + @IsString() + note?: string; + + @IsOptional() + @IsString() + actorAdminUserId?: string; +} diff --git a/apps/api/src/modules/orders/orders.controller.ts b/apps/api/src/modules/orders/orders.controller.ts new file mode 100644 index 0000000..f8bf00c --- /dev/null +++ b/apps/api/src/modules/orders/orders.controller.ts @@ -0,0 +1,24 @@ +import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common'; +import { OrdersService } from './orders.service'; +import { CreateOrderDto } from './dto/create-order.dto'; +import { UpdateOrderStatusDto } from './dto/update-order-status.dto'; + +@Controller('orders') +export class OrdersController { + constructor(private readonly orders: OrdersService) {} + + @Post() + create(@Body() dto: CreateOrderDto) { + return this.orders.createOrder(dto); + } + + @Get(':id') + get(@Param('id') id: string) { + return this.orders.getById(id); + } + + @Patch(':id/status') + updateStatus(@Param('id') id: string, @Body() dto: UpdateOrderStatusDto) { + return this.orders.updateStatus(id, dto.status, dto.actorAdminUserId, dto.note); + } +} diff --git a/apps/api/src/modules/orders/orders.module.ts b/apps/api/src/modules/orders/orders.module.ts new file mode 100644 index 0000000..d7316d1 --- /dev/null +++ b/apps/api/src/modules/orders/orders.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { OrdersService } from './orders.service'; +import { OrdersController } from './orders.controller'; +import { CatalogModule } from '../catalog/catalog.module'; +import { AccountingModule } from '../accounting/accounting.module'; + +@Module({ + imports: [CatalogModule, AccountingModule], + controllers: [OrdersController], + providers: [OrdersService], + exports: [OrdersService], +}) +export class OrdersModule {} diff --git a/apps/api/src/modules/orders/orders.service.ts b/apps/api/src/modules/orders/orders.service.ts new file mode 100644 index 0000000..89b9a7c --- /dev/null +++ b/apps/api/src/modules/orders/orders.service.ts @@ -0,0 +1,151 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { OrderStatus, Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { CatalogService } from '../catalog/catalog.service'; +import { LedgerService } from '../accounting/ledger.service'; +import { CreateOrderDto } from './dto/create-order.dto'; + +interface ComputedItem { + productVariantId: string; + quantity: number; + rashWeightGramsSnapshot: Prisma.Decimal; + wagePercentSnapshot: Prisma.Decimal; + discountPercentSnapshot: Prisma.Decimal; + computedWeightGrams: Prisma.Decimal; + flatRialCharge: Prisma.Decimal; +} + +@Injectable() +export class OrdersService { + constructor( + private readonly prisma: PrismaService, + private readonly catalog: CatalogService, + private readonly ledger: LedgerService, + ) {} + + /** + * Places a stock order. Per line item: + * w = rashWeightGrams * (1 + (wagePercent - discountPercent) / 100) + * mirrors legacy ApiMobileController::setOrder. Debt is NOT posted here — + * only once the order reaches DELIVERED_TO_AGENT (see updateStatus). + */ + async createOrder(dto: CreateOrderDto) { + const customer = await this.prisma.customer.findUnique({ where: { id: dto.customerId } }); + if (!customer) throw new NotFoundException(`Customer ${dto.customerId} not found`); + + const computedItems: ComputedItem[] = await Promise.all( + dto.items.map(async (item) => { + const variant = await this.catalog.getVariantOrThrow(item.productVariantId); + const rashWeight = new Prisma.Decimal(variant.rashWeightGrams); + const wagePercent = new Prisma.Decimal(item.wagePercent); + const discountPercent = new Prisma.Decimal(item.discountPercent ?? 0); + const netPercent = wagePercent.sub(discountPercent); + const computedWeightGrams = rashWeight.mul(netPercent.div(100).add(1)); + + return { + productVariantId: variant.id, + quantity: item.quantity, + rashWeightGramsSnapshot: rashWeight, + wagePercentSnapshot: wagePercent, + discountPercentSnapshot: discountPercent, + computedWeightGrams, + flatRialCharge: new Prisma.Decimal(item.flatRialCharge ?? 0), + }; + }), + ); + + const totalGoldWeightGrams = computedItems.reduce( + (sum, item) => sum.add(item.computedWeightGrams.mul(item.quantity)), + new Prisma.Decimal(0), + ); + const totalRashWeightGrams = computedItems.reduce( + (sum, item) => sum.add(item.rashWeightGramsSnapshot.mul(item.quantity)), + new Prisma.Decimal(0), + ); + const wageWeightGrams = totalGoldWeightGrams.sub(totalRashWeightGrams); + const flatRialCharge = computedItems.reduce( + (sum, item) => sum.add(item.flatRialCharge.mul(item.quantity)), + new Prisma.Decimal(0), + ); + + // legacy: inValidOrder = (allGram + customer.remain) > customer.gram_level + const isSuspendedOverCreditLimit = totalGoldWeightGrams + .add(customer.goldDebtGrams) + .gt(customer.goldCreditLimitGrams); + const initialStatus = isSuspendedOverCreditLimit ? OrderStatus.SUSPENDED : OrderStatus.PLACED; + + return this.prisma.$transaction(async (tx) => { + const order = await tx.order.create({ + data: { + orderNumber: this.generateOrderNumber(), + customerId: dto.customerId, + branchId: dto.branchId, + status: initialStatus, + totalGoldWeightGrams, + wageWeightGrams, + flatRialCharge, + isSuspendedOverCreditLimit, + items: { + create: computedItems.map((item) => ({ + productVariantId: item.productVariantId, + quantity: item.quantity, + rashWeightGramsSnapshot: item.rashWeightGramsSnapshot, + wagePercentSnapshot: item.wagePercentSnapshot, + discountPercentSnapshot: item.discountPercentSnapshot, + computedWeightGrams: item.computedWeightGrams, + flatRialCharge: item.flatRialCharge, + })), + }, + statusEvents: { + create: { toStatus: initialStatus, note: isSuspendedOverCreditLimit ? 'Credit limit exceeded' : undefined }, + }, + }, + include: { items: true, statusEvents: true }, + }); + + return order; + }); + } + + async updateStatus(orderId: string, newStatus: OrderStatus, actorAdminUserId?: string, note?: string) { + return this.prisma.$transaction(async (tx) => { + const order = await tx.order.findUnique({ where: { id: orderId } }); + if (!order) throw new NotFoundException(`Order ${orderId} not found`); + + const updated = await tx.order.update({ + where: { id: orderId }, + data: { + status: newStatus, + deliveredAt: newStatus === OrderStatus.DELIVERED_TO_CUSTOMER ? new Date() : order.deliveredAt, + }, + }); + + await tx.orderStatusEvent.create({ + data: { orderId, fromStatus: order.status, toStatus: newStatus, note, actorAdminUserId }, + }); + + // Debt recognition point — mirrors legacy sts>4, which first becomes true + // at sts=5 ("تحویل مسئول فروش" / delivered to agent), not sts=6 (delivered + // to customer). Only fires once: a second transition into this status + // (e.g. a data-fix re-save) would double-post debt, so guard on the order + // not already having deliveredAt. + if (newStatus === OrderStatus.DELIVERED_TO_AGENT && !order.deliveredAt) { + await this.ledger.postOrderDebt(orderId, tx); + } + + return updated; + }); + } + + getById(orderId: string) { + return this.prisma.order.findUnique({ + where: { id: orderId }, + include: { items: true, statusEvents: true, ledgerEntries: true }, + }); + } + + private generateOrderNumber(): string { + const random = Math.random().toString(36).slice(2, 8).toUpperCase(); + return `ORD-${Date.now()}-${random}`; + } +} diff --git a/apps/api/src/modules/pricing/pricing.controller.ts b/apps/api/src/modules/pricing/pricing.controller.ts new file mode 100644 index 0000000..2b5e1d3 --- /dev/null +++ b/apps/api/src/modules/pricing/pricing.controller.ts @@ -0,0 +1,12 @@ +import { Controller, Get, Query } from '@nestjs/common'; +import { PricingService } from './pricing.service'; + +@Controller('pricing') +export class PricingController { + constructor(private readonly pricing: PricingService) {} + + @Get('latest') + getLatest(@Query('karatPerMille') karatPerMille: string) { + return this.pricing.getLatestQuote(Number(karatPerMille) || 750); + } +} diff --git a/apps/api/src/modules/pricing/pricing.module.ts b/apps/api/src/modules/pricing/pricing.module.ts new file mode 100644 index 0000000..6688c69 --- /dev/null +++ b/apps/api/src/modules/pricing/pricing.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { PricingService } from './pricing.service'; +import { PricingController } from './pricing.controller'; + +@Module({ + controllers: [PricingController], + providers: [PricingService], + exports: [PricingService], +}) +export class PricingModule {} diff --git a/apps/api/src/modules/pricing/pricing.service.ts b/apps/api/src/modules/pricing/pricing.service.ts new file mode 100644 index 0000000..910f4ed --- /dev/null +++ b/apps/api/src/modules/pricing/pricing.service.ts @@ -0,0 +1,23 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { PrismaService } from '../../prisma/prisma.service'; + +@Injectable() +export class PricingService { + constructor(private readonly prisma: PrismaService) {} + + async recordQuote(karatPerMille: number, pricePerGramRial: Prisma.Decimal.Value, source: string) { + return this.prisma.goldPriceQuote.create({ + data: { karatPerMille, pricePerGramRial, source }, + }); + } + + async getLatestQuote(karatPerMille: number) { + const quote = await this.prisma.goldPriceQuote.findFirst({ + where: { karatPerMille }, + orderBy: { quotedAt: 'desc' }, + }); + if (!quote) throw new NotFoundException(`No gold price quote recorded for ${karatPerMille}‰`); + return quote; + } +} diff --git a/apps/api/src/prisma/prisma-exception.filter.ts b/apps/api/src/prisma/prisma-exception.filter.ts new file mode 100644 index 0000000..da93c8f --- /dev/null +++ b/apps/api/src/prisma/prisma-exception.filter.ts @@ -0,0 +1,63 @@ +import { ArgumentsHost, Catch, ExceptionFilter, HttpStatus, Logger } from '@nestjs/common'; +import { Response } from 'express'; +import { Prisma } from '@prisma/client'; + +/** + * Without this, an unmigrated table (P2021), a bad foreign key (P2003), etc. + * all surfaced as an opaque "Internal Server Error" with no indication of + * the actual cause — this maps the common Prisma error codes to sensible + * HTTP statuses and echoes the real reason back (safe in dev; consider + * stripping detail in production). + */ +@Catch(Prisma.PrismaClientKnownRequestError, Prisma.PrismaClientValidationError) +export class PrismaExceptionFilter implements ExceptionFilter { + private readonly logger = new Logger('PrismaExceptionFilter'); + + catch(exception: Prisma.PrismaClientKnownRequestError | Prisma.PrismaClientValidationError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + + this.logger.error(exception.message); + + if (exception instanceof Prisma.PrismaClientValidationError) { + return response.status(HttpStatus.BAD_REQUEST).json({ + statusCode: HttpStatus.BAD_REQUEST, + error: 'Invalid request shape for the database query', + message: exception.message.split('\n').pop()?.trim(), + }); + } + + switch (exception.code) { + case 'P2021': // table does not exist — schema not pushed/migrated + return response.status(HttpStatus.SERVICE_UNAVAILABLE).json({ + statusCode: HttpStatus.SERVICE_UNAVAILABLE, + error: 'Database schema not initialized', + message: `${exception.meta?.table ?? 'A required table'} does not exist. Run "pnpm prisma:push" (or "pnpm prisma:migrate") against this DATABASE_URL.`, + }); + case 'P2025': // record not found for update/delete + return response.status(HttpStatus.NOT_FOUND).json({ + statusCode: HttpStatus.NOT_FOUND, + error: 'Not found', + message: exception.meta?.cause ?? 'Record not found', + }); + case 'P2002': // unique constraint violation + return response.status(HttpStatus.CONFLICT).json({ + statusCode: HttpStatus.CONFLICT, + error: 'Conflict', + message: `Unique constraint failed on: ${(exception.meta?.target as string[] | undefined)?.join(', ')}`, + }); + case 'P2003': // foreign key constraint violation + return response.status(HttpStatus.BAD_REQUEST).json({ + statusCode: HttpStatus.BAD_REQUEST, + error: 'Invalid reference', + message: `Foreign key constraint failed on: ${exception.meta?.field_name}`, + }); + default: + return response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + error: 'Database error', + message: `[${exception.code}] ${exception.message.split('\n').pop()?.trim()}`, + }); + } + } +} diff --git a/apps/api/src/prisma/prisma.module.ts b/apps/api/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/apps/api/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts new file mode 100644 index 0000000..623d5e0 --- /dev/null +++ b/apps/api/src/prisma/prisma.service.ts @@ -0,0 +1,13 @@ +import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { PrismaClient } from '@prisma/client'; + +@Injectable() +export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy { + async onModuleInit() { + await this.$connect(); + } + + async onModuleDestroy() { + await this.$disconnect(); + } +} diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..8722308 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "outDir": "./dist", + "baseUrl": "./", + "incremental": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "strictNullChecks": true, + "sourceMap": true + } +} diff --git a/apps/user-app/index.html b/apps/user-app/index.html new file mode 100644 index 0000000..def503c --- /dev/null +++ b/apps/user-app/index.html @@ -0,0 +1,53 @@ + + + + + + + + + + + طلای هنزا + + + +
+
+ + + پشتیبانی واتساپ + + + diff --git a/apps/user-app/package.json b/apps/user-app/package.json new file mode 100644 index 0000000..92f4a3e --- /dev/null +++ b/apps/user-app/package.json @@ -0,0 +1,38 @@ +{ + "name": "user-app", + "version": "0.0.1", + "private": true, + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "lint": "eslint \"src/**/*.{ts,vue}\"" + }, + "dependencies": { + "@ant-design/icons-vue": "^6.1.0", + "@gold-bank/shared-types": "workspace:*", + "@vueuse/core": "^9.13.0", + "ant-design-vue": "^3.2.20", + "gsap": "^3.9.1", + "moment-jalaali": "^0.10.0", + "pinia": "^2.2.0", + "swiper": "^7.4.1", + "vant": "^4.0.3", + "vue": "^3.5.0", + "vue-router": "^4.4.0", + "vue3-lazyload": "^0.2.5-beta", + "@lottiefiles/dotlottie-vue": "^0.4.15" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.1.0", + "autoprefixer": "^10.4.2", + "less": "^4.1.2", + "postcss": "^8.4.5", + "tailwindcss": "3.0.18", + "typescript": "^5.5.0", + "vite": "^5.4.0", + "vue-tsc": "^2.1.0", + "vite-plugin-pwa": "^0.20.5", + "workbox-precaching": "^7.1.0", + "workbox-window": "^7.1.0" + } +} diff --git a/apps/user-app/postcss.config.js b/apps/user-app/postcss.config.js new file mode 100644 index 0000000..7e945b8 --- /dev/null +++ b/apps/user-app/postcss.config.js @@ -0,0 +1,7 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + // "postcss-rtl": {}, // present as a dependency in legacy but disabled/commented out — not ported + }, +} diff --git a/apps/user-app/public/bale.png b/apps/user-app/public/bale.png new file mode 100644 index 0000000..b5ae464 Binary files /dev/null and b/apps/user-app/public/bale.png differ diff --git a/apps/user-app/public/favicon.ico b/apps/user-app/public/favicon.ico new file mode 100644 index 0000000..6ee8c33 Binary files /dev/null and b/apps/user-app/public/favicon.ico differ diff --git a/apps/user-app/public/icon-192.png b/apps/user-app/public/icon-192.png new file mode 100644 index 0000000..368211c Binary files /dev/null and b/apps/user-app/public/icon-192.png differ diff --git a/apps/user-app/public/icon-512.png b/apps/user-app/public/icon-512.png new file mode 100644 index 0000000..368211c Binary files /dev/null and b/apps/user-app/public/icon-512.png differ diff --git a/apps/user-app/public/img/henza1.svg b/apps/user-app/public/img/henza1.svg new file mode 100644 index 0000000..006e601 --- /dev/null +++ b/apps/user-app/public/img/henza1.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/public/img/no-items.png b/apps/user-app/public/img/no-items.png new file mode 100644 index 0000000..9fdcf7c Binary files /dev/null and b/apps/user-app/public/img/no-items.png differ diff --git a/apps/user-app/public/manifest.json b/apps/user-app/public/manifest.json new file mode 100644 index 0000000..bbbd281 --- /dev/null +++ b/apps/user-app/public/manifest.json @@ -0,0 +1,3 @@ +{ + "gcm_sender_id": "464066939468" +} diff --git a/apps/user-app/public/whatsapp.png b/apps/user-app/public/whatsapp.png new file mode 100644 index 0000000..842493f Binary files /dev/null and b/apps/user-app/public/whatsapp.png differ diff --git a/apps/user-app/src/App.vue b/apps/user-app/src/App.vue new file mode 100644 index 0000000..9ea8310 --- /dev/null +++ b/apps/user-app/src/App.vue @@ -0,0 +1,129 @@ + + + + + + diff --git a/apps/user-app/src/api/client.ts b/apps/user-app/src/api/client.ts new file mode 100644 index 0000000..2b2f9bb --- /dev/null +++ b/apps/user-app/src/api/client.ts @@ -0,0 +1,135 @@ +import type { CreateOrderDto, CustomerBalanceDto } from '@gold-bank/shared-types'; + +const BASE = '/api'; + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { 'Content-Type': 'application/json' }, + ...options, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`API ${path} failed (${res.status}): ${body}`); + } + return res.json(); +} + +export interface ProductVariantSummary { + id: string; + title: string; + rashWeightGrams: string; + defaultWagePercent: string; +} + +export interface CategorySummary { + id: string; + title: string; + parentId: string | null; +} + +export interface BrandSummary { + id: string; + title: string; +} + +export interface CollectionSummary { + id: string; + title: string; + description: string | null; + isActive: boolean; +} + +export interface ProductSummary { + id: string; + title: string; + description: string | null; + variants: ProductVariantSummary[]; + images: Array<{ url: string }>; + category: CategorySummary | null; + brand: BrandSummary | null; + collections: Array<{ collectionId: string; collection: CollectionSummary }>; +} + +export interface LedgerEntrySummary { + id: string; + currency: string; + direction: string; + sourceType: string; + amount: string; + confirmed: boolean; + createdAt: string; + note: string | null; +} + +export interface CustomerSummary { + id: string; + fullName: string; + mobile: string; + goldDebtGrams: string; + fiatDebtRial: string; + goldCreditLimitGrams: string; +} + +export interface OrderSummary { + id: string; + orderNumber: string; + status: string; + totalGoldWeightGrams: string; + isSuspendedOverCreditLimit: boolean; +} + +export interface OrderItemDetail { + id: string; + productVariantId: string; + quantity: number; + rashWeightGramsSnapshot: string; + wagePercentSnapshot: string; + discountPercentSnapshot: string; + computedWeightGrams: string; + flatRialCharge: string; +} + +export interface OrderDetail { + id: string; + orderNumber: string; + status: string; + totalGoldWeightGrams: string; + wageWeightGrams: string; + flatRialCharge: string; + isSuspendedOverCreditLimit: boolean; + deliveredAt: string | null; + createdAt: string; + items: OrderItemDetail[]; +} + +export interface CustomerDetail { + id: string; + fullName: string; + mobile: string; + nationalCode: string; + cityId: string | null; + goldCreditLimitGrams: string; + goldDebtGrams: string; + fiatDebtRial: string; + verificationStep: string; + createdAt: string; +} + +export interface GoldPriceQuote { + id: string; + karatPerMille: number; + pricePerGramRial: string; + source: string; + quotedAt: string; +} + +export const api = { + listProducts: () => request('/catalog/products'), + listCustomers: () => request('/customers'), + getCustomer: (id: string) => request(`/customers/${id}`), + getBalance: (customerId: string) => request(`/accounting/customers/${customerId}/balance`), + getLedgerEntries: (customerId: string) => request(`/customers/${customerId}/ledger-entries`), + createOrder: (dto: CreateOrderDto) => request('/orders', { method: 'POST', body: JSON.stringify(dto) }), + getCustomerOrders: (customerId: string) => request(`/customers/${customerId}/orders`), + getLatestGoldPrice: (karatPerMille = 750) => request(`/pricing/latest?karatPerMille=${karatPerMille}`), +}; diff --git a/apps/user-app/src/assets/img/1020.jpg b/apps/user-app/src/assets/img/1020.jpg new file mode 100644 index 0000000..c8a5b88 Binary files /dev/null and b/apps/user-app/src/assets/img/1020.jpg differ diff --git a/apps/user-app/src/assets/img/Vector.svg b/apps/user-app/src/assets/img/Vector.svg new file mode 100644 index 0000000..733693a --- /dev/null +++ b/apps/user-app/src/assets/img/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/user-app/src/assets/img/empty/cartEmpty.svg b/apps/user-app/src/assets/img/empty/cartEmpty.svg new file mode 100644 index 0000000..7cd6f6a --- /dev/null +++ b/apps/user-app/src/assets/img/empty/cartEmpty.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/customerEmpty.svg b/apps/user-app/src/assets/img/empty/customerEmpty.svg new file mode 100644 index 0000000..31691a6 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/customerEmpty.svg @@ -0,0 +1,447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/dialogEmpty1.svg b/apps/user-app/src/assets/img/empty/dialogEmpty1.svg new file mode 100644 index 0000000..7763aca --- /dev/null +++ b/apps/user-app/src/assets/img/empty/dialogEmpty1.svg @@ -0,0 +1,447 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/dialogEmpty2.svg b/apps/user-app/src/assets/img/empty/dialogEmpty2.svg new file mode 100644 index 0000000..fae661f --- /dev/null +++ b/apps/user-app/src/assets/img/empty/dialogEmpty2.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/dialogEmpty3.svg b/apps/user-app/src/assets/img/empty/dialogEmpty3.svg new file mode 100644 index 0000000..ad432f2 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/dialogEmpty3.svg @@ -0,0 +1,127 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/dialogEmpty4.svg b/apps/user-app/src/assets/img/empty/dialogEmpty4.svg new file mode 100644 index 0000000..043d4f6 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/dialogEmpty4.svg @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/dialogEmpty5.svg b/apps/user-app/src/assets/img/empty/dialogEmpty5.svg new file mode 100644 index 0000000..ddf8533 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/dialogEmpty5.svg @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/emp_after_search.svg b/apps/user-app/src/assets/img/empty/emp_after_search.svg new file mode 100644 index 0000000..48cc7d7 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/emp_after_search.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/emp_basket.svg b/apps/user-app/src/assets/img/empty/emp_basket.svg new file mode 100644 index 0000000..05bf2db --- /dev/null +++ b/apps/user-app/src/assets/img/empty/emp_basket.svg @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/emp_before_search.svg b/apps/user-app/src/assets/img/empty/emp_before_search.svg new file mode 100644 index 0000000..698a148 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/emp_before_search.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/emp_favorite.svg b/apps/user-app/src/assets/img/empty/emp_favorite.svg new file mode 100644 index 0000000..46948ad --- /dev/null +++ b/apps/user-app/src/assets/img/empty/emp_favorite.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/emp_notification.svg b/apps/user-app/src/assets/img/empty/emp_notification.svg new file mode 100644 index 0000000..31c143c --- /dev/null +++ b/apps/user-app/src/assets/img/empty/emp_notification.svg @@ -0,0 +1,37 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/emp_search.svg b/apps/user-app/src/assets/img/empty/emp_search.svg new file mode 100644 index 0000000..c6128cd --- /dev/null +++ b/apps/user-app/src/assets/img/empty/emp_search.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/favoritEmpty.svg b/apps/user-app/src/assets/img/empty/favoritEmpty.svg new file mode 100644 index 0000000..84f3293 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/favoritEmpty.svg @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/gozareshEmpty.svg b/apps/user-app/src/assets/img/empty/gozareshEmpty.svg new file mode 100644 index 0000000..5ba18b9 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/gozareshEmpty.svg @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/ordersEmpty.svg b/apps/user-app/src/assets/img/empty/ordersEmpty.svg new file mode 100644 index 0000000..015c599 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/ordersEmpty.svg @@ -0,0 +1,90 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/empty/transactionEmpty.svg b/apps/user-app/src/assets/img/empty/transactionEmpty.svg new file mode 100644 index 0000000..126bc79 --- /dev/null +++ b/apps/user-app/src/assets/img/empty/transactionEmpty.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/henza.svg b/apps/user-app/src/assets/img/henza.svg new file mode 100644 index 0000000..b4f04e1 --- /dev/null +++ b/apps/user-app/src/assets/img/henza.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/user-app/src/assets/img/no-items.png b/apps/user-app/src/assets/img/no-items.png new file mode 100644 index 0000000..9fdcf7c Binary files /dev/null and b/apps/user-app/src/assets/img/no-items.png differ diff --git a/apps/user-app/src/assets/img/order-open.png b/apps/user-app/src/assets/img/order-open.png new file mode 100644 index 0000000..4010769 Binary files /dev/null and b/apps/user-app/src/assets/img/order-open.png differ diff --git a/apps/user-app/src/assets/img/pattern.svg b/apps/user-app/src/assets/img/pattern.svg new file mode 100644 index 0000000..97efd9b --- /dev/null +++ b/apps/user-app/src/assets/img/pattern.svg @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/pattern2.svg b/apps/user-app/src/assets/img/pattern2.svg new file mode 100644 index 0000000..4b4993c --- /dev/null +++ b/apps/user-app/src/assets/img/pattern2.svg @@ -0,0 +1,298 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/pattern3.svg b/apps/user-app/src/assets/img/pattern3.svg new file mode 100644 index 0000000..b2e77a5 --- /dev/null +++ b/apps/user-app/src/assets/img/pattern3.svg @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/apps/user-app/src/assets/img/registerDelay.svg b/apps/user-app/src/assets/img/registerDelay.svg new file mode 100644 index 0000000..a4295ab --- /dev/null +++ b/apps/user-app/src/assets/img/registerDelay.svg @@ -0,0 +1,80 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/user-app/src/assets/img/shipping-and-delivery.png b/apps/user-app/src/assets/img/shipping-and-delivery.png new file mode 100644 index 0000000..7909fea Binary files /dev/null and b/apps/user-app/src/assets/img/shipping-and-delivery.png differ diff --git a/apps/user-app/src/assets/img/sports-and-competition.png b/apps/user-app/src/assets/img/sports-and-competition.png new file mode 100644 index 0000000..47b77fa Binary files /dev/null and b/apps/user-app/src/assets/img/sports-and-competition.png differ diff --git a/apps/user-app/src/assets/styles/fonts.less b/apps/user-app/src/assets/styles/fonts.less new file mode 100644 index 0000000..0fbfc44 --- /dev/null +++ b/apps/user-app/src/assets/styles/fonts.less @@ -0,0 +1,72 @@ +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: bold; + src: url('./fonts/yekan_bakh_fanum_bold.eot'); + src: url('./fonts/yekan_bakh_fanum_bold.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('./fonts/yekan_bakh_fanum_bold.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('./fonts/yekan_bakh_fanum_bold.ttf') format('truetype'); +} + + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 100; + src: url('./fonts/yekan_bakh_fanum_thin.eot'); + src: url('./fonts/yekan_bakh_fanum_thin.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('./fonts/yekan_bakh_fanum_thin.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('./fonts/yekan_bakh_fanum_thin.ttf') format('truetype'); +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 300; + src: url('./fonts/yekan_bakh_fanum_light.eot'); + src: url('./fonts/yekan_bakh_fanum_light.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('./fonts/yekan_bakh_fanum_light.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('./fonts/yekan_bakh_fanum_light.ttf') format('truetype'); +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: normal; + src: url('./fonts/yekan_bakh_fanum_regular.eot'); + src: url('./fonts/yekan_bakh_fanum_regular.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('./fonts/yekan_bakh_fanum_regular.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('./fonts/yekan_bakh_fanum_regular.ttf') format('truetype'); +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 500; + src: url('./fonts/yekan_bakh_fanum_medium.eot'); + src: url('./fonts/yekan_bakh_fanum_medium.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('./fonts/yekan_bakh_fanum_medium.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('./fonts/yekan_bakh_fanum_medium.ttf') format('truetype'); +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 800; + src: url('./fonts/yekan_bakh_fanum_fat.eot'); + src: url('./fonts/yekan_bakh_fanum_fat.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('./fonts/yekan_bakh_fanum_heavy.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('./fonts/yekan_bakh_fanum_fat.ttf') format('truetype'); +} + + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 900; + src: url('./fonts/yekan_bakh_fanum_heavy.eot'); + src: url('./fonts/yekan_bakh_fanum_heavy.eot?#iefix') format('embedded-opentype'), /* IE6-8 */ + url('./fonts/yekan_bakh_fanum_heavy.woff') format('woff'), /* FF3.6+, IE9, Chrome6+, Saf5.1+*/ + url('./fonts/yekan_bakh_fanum_heavy.ttf') format('truetype'); +} + diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.eot new file mode 100644 index 0000000..0c17e46 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.ttf new file mode 100644 index 0000000..43c27a5 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.woff new file mode 100644 index 0000000..5b48b59 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_bold.woff differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.eot new file mode 100644 index 0000000..9d30394 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.ttf new file mode 100644 index 0000000..53f93ca Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.woff new file mode 100644 index 0000000..0be38e0 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_fat.woff differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.eot new file mode 100644 index 0000000..3fbabb0 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.ttf new file mode 100644 index 0000000..4d668f8 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.woff new file mode 100644 index 0000000..bc39341 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_hairline.woff differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.eot new file mode 100644 index 0000000..2d20fe9 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.ttf new file mode 100644 index 0000000..a4ca639 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.woff new file mode 100644 index 0000000..c518fcb Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_heavy.woff differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.eot new file mode 100644 index 0000000..ec0b456 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.ttf new file mode 100644 index 0000000..417a433 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.woff new file mode 100644 index 0000000..d8262a3 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_light.woff differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.eot new file mode 100644 index 0000000..8f5977f Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.ttf new file mode 100644 index 0000000..601aff4 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.woff new file mode 100644 index 0000000..920e9b3 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_medium.woff differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.eot new file mode 100644 index 0000000..b41ca40 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.ttf new file mode 100644 index 0000000..4376b3a Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.woff new file mode 100644 index 0000000..88b13b1 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_regular.woff differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.eot b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.eot new file mode 100644 index 0000000..c678bda Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.eot differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.ttf b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.ttf new file mode 100644 index 0000000..9ab1aa2 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.ttf differ diff --git a/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.woff b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.woff new file mode 100644 index 0000000..4517964 Binary files /dev/null and b/apps/user-app/src/assets/styles/fonts/yekan_bakh_fanum_thin.woff differ diff --git a/apps/user-app/src/assets/styles/style.less b/apps/user-app/src/assets/styles/style.less new file mode 100644 index 0000000..dbb11da --- /dev/null +++ b/apps/user-app/src/assets/styles/style.less @@ -0,0 +1,609 @@ +@import './fonts.less'; +/*@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: bold; + src: url('iranyekan_fonts/eot/iranyekanwebboldfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanwebboldfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanwebboldfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanwebboldfanum.ttf') format('truetype'); +} + + +@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: 100; + src: url('iranyekan_fonts/eot/iranyekanwebthinfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanwebthinfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanwebthinfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanwebthinfanum.ttf') format('truetype'); +} + +@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: 300; + src: url('iranyekan_fonts/eot/iranyekanweblightfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanweblightfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanweblightfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanweblightfanum.ttf') format('truetype'); +} + +@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: normal; + src: url('iranyekan_fonts/eot/iranyekanwebregularfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanwebregularfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanwebregularfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanwebregularfanum.ttf') format('truetype'); +} + +@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: 500; + src: url('iranyekan_fonts/eot/iranyekanwebmediumfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanwebmediumfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanwebmediumfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanwebmediumfanum.ttf') format('truetype'); +} + +@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: 800; + src: url('iranyekan_fonts/eot/iranyekanwebextraboldfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanwebextraboldfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanwebextraboldfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanwebextraboldfanum.ttf') format('truetype'); +} + +@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: 850; + src: url('iranyekan_fonts/eot/iranyekanwebblackfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanwebblackfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanwebblackfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanwebblackfanum.ttf') format('truetype'); +} + +@font-face { + font-family: iranyekan; + font-style: normal; + font-weight: 900; + src: url('iranyekan_fonts/eot/iranyekanwebextrablackfanum.eot'); + src: url('iranyekan_fonts/eot/iranyekanwebextrablackfanum.eot?#iefix') format('embedded-opentype'), !* IE6-8 *! + url('iranyekan_fonts/woff/iranyekanwebextrablackfanum.woff') format('woff'), !* FF3.6+, IE9, Chrome6+, Saf5.1+*! + url('iranyekan_fonts/ttf/iranyekanwebextrablackfanum.ttf') format('truetype'); +}*/ + +/*.iranyekan{ + font-family: iranyekan , arial, helvetica, sans-serif !important; +}*/ + +///////////////////////////////// +/*@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 100; + src: url('fonts/woff/yekan_bakh_fanum_thin.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_thin.woff2') format('woff2'); +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 300; + src: url('fonts/woff/yekan_bakh_fanum_light.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_light.woff2') format('woff2'); + +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: normal; + src: url('fonts/woff/yekan_bakh_fanum_regular.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_regular.woff2') format('woff2'); + +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 600; + src: url('fonts/woff/yekan_bakh_fanum_semi_bold.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_semi_bold.woff2') format('woff2'); + +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: bold; + src: url('fonts/woff/yekan_bakh_fanum_bold.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_bold.woff2') format('woff2'); + +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 800; + src: url('fonts/woff/yekan_bakh_fanum_extra_bold.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_extra_bold.woff2') format('woff2'); + +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 900; + src: url('fonts/woff/yekan_bakh_fanum_black.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_black.woff2') format('woff2'); + +} + +@font-face { + font-family: yekan_bakh; + font-style: normal; + font-weight: 950; + src: url('fonts/woff/yekan_bakh_fanum_extra_black.woff') format('woff'), + url('fonts/woff2/yekan_bakh_fanum_extra_black.woff2') format('woff2'); +} +.yekan_bakh{ + font-family: yekan_bakh , arial, helvetica, sans-serif +}*/ + + +////////////////////////////////// +/*@font-face { + font-family: 'sedmedgh'; + src: url("./font/ligth.ttf"); + font-weight: 300; +} + +@font-face { + font-family: 'sedmedgh'; + src: url("./font/regular.ttf"); + font-weight: 400; +} + +@font-face { + font-family: 'sedmedgh'; + src: url("./font/medium.ttf"); + font-weight: 500; +} + +@font-face { + font-family: 'sedmedgh'; + src: url("./font/bold.ttf"); + font-weight: 600; +} + +@font-face { + font-family: 'sedmedgh'; + src: url("./font/heavy.ttf"); + font-weight: 700; +} + +@font-face { + font-family: 'sedmedgh'; + src: url("./font/fat.ttf"); + font-weight: 800; +}*/ + +/////////////////////////////////////////// + +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/thin.ttf"); +// font-weight: 300; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/light.ttf"); +// font-weight: 400; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/regular.ttf"); +// font-weight: 500; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/medium.ttf"); +// font-weight: 600; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/bold.ttf"); +// font-weight: 700; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/heavy.ttf"); +// font-weight: 800; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/fat.ttf"); +// font-weight: 900; +//} +// +/////////////////////////////////////// +// +// +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/light.ttf"); +// font-weight: 300; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/regular.ttf"); +// font-weight: 400; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/medium.ttf"); +// font-weight: 500; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/bold.ttf"); +// font-weight: 600; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/heavy.ttf"); +// font-weight: 700; +//} +// +//@font-face { +// font-family: 'sedmedgh'; +// src: url("./font/iranekan/fat.ttf"); +// font-weight: 800; +//} + + + + +body, * { + font-family: yekan_bakh , sans-serif, tahoma; + //letter-spacing: 0px !important; + font-size: @font-size; + line-height: inherit; + font-weight: inherit; +} + + + +.rtl { + direction: rtl !important; +} + +.ltr { + direction: ltr !important; +} + +.no-scrollbar { + &::-webkit-scrollbar { + display: none; + } +} +*{ + .no-scrollbar; +} +// headings +h1, h2, h3, h4, h5, h6, h7, h8 { + margin-bottom: 1.2em; + color: @primary; +} + +h1, .h1 { + font-size: @h1-font-size; +} + +h2, .h2 { + font-size: @h2-font-size; +} + +h3, .h3 { + font-size: @h3-font-size; +} + +h4, .h4 { + font-size: @h4-font-size; +} + +h5, .h5 { + font-size: @h5-font-size; +} + +h6, .h6 { + font-size: @h6-font-size; +} + +p { + margin-bottom: 10px; +} + +html, body { + overscroll-behavior-y: contain; +} + +.overflow-x-auto { + overscroll-behavior-x: contain; +} + +.overflow-y-auto { + overscroll-behavior-y: contain; +} + +input:focus-visible { + outline: none; +} + +.@{prefix} { + &-form { + &.ant-form { + background: transparent !important; + color: @white !important; + } + + .ant-btn-primary[disabled], .ant-btn-primary[disabled]:hover, .ant-btn-primary[disabled]:focus, .ant-btn-primary[disabled]:active { + background: fade(@white, 20%); + color: fade(@white, 50%); + border-color: transparent; + } + + .ant-form-item { + label, input { + background: transparent !important; + color: @white !important; + } + } + } + + &-rounded-drawer { + background: transparent; + + .ant-drawer-content-wrapper { + box-shadow: none !important; + } + + .ant-drawer-content { + background: transparent !important;; + } + &.bg-white .ant-drawer-body { + background: white; + border-radius: 15px 15px 0px 0px; + + } + &.bg-primaryLighten .ant-drawer-body { + background: @primaryLighten; + border-radius: 30px 30px 0px 0px; + } + } + + &-custom-scrollbar { + padding: 0 10px; + + &::-webkit-scrollbar { + width: 8px; + } + + /* Handle */ + + &::-webkit-scrollbar-thumb { + background: transparent; + border-radius: 10px; + } + + /* Handle on hover */ + + &:hover::-webkit-scrollbar-thumb { + background: #ffffff80; + } + } +} + +.ant { + &-empty-image img{ + object-fit: contain; + } + + &-timeline-item-content { + margin: 0 26px 0 0; + } + + &-timeline-item-tail { + left: unset; + right: 4px; + } + + &-btn { + display: flex !important; + flex-direction: row-reverse; + align-items: center; + justify-content: center; + + i { + margin-right: 10px; + } + + span { + font-weight: 600; + } + + &-lg { + span { + font-size: 20px; + } + } + + &-secondary, + &-secondary:hover, + &-secondary:focus { + border-color: @primary; + background: @primary; + color: @white; + + &.ant-btn-background-ghost { + color: @primary; + border-color: @primary; + text-shadow: none; + } + } + + &-light, + &-light:hover, + &-light:focus { + color: @primary; + border-color: @light; + background: @light; + } + } + + &-form-vertical .ant-form-item-label, .ant-col-24.ant-form-item-label, .ant-col-xl-24.ant-form-item-label { + text-align: right !important; + + } +//&-ant-form-item-label > label{ +// font-size: 50px!important; +//} + &-tabs-tab + .ant-tabs-tab { + margin: 0 32px 0 0; + } + + &-tabs > .ant-tabs-nav .ant-tabs-nav-wrap, .ant-tabs > div > .ant-tabs-nav .ant-tabs-nav-wrap { + justify-content: flex-end; + } + + &-drawer-bottom.rtl { + .ant-drawer-body { + .no-scrollbar; + } + } + + &-radio-button-wrapper { + border-left-width: 1.02px; + + &:not(:first-child)::before { + left: unset; + right: -1px; + } + + &:first-child { + border-radius: 0 5px 5px 0; + } + + &:last-child { + border-radius: 5px 0 0 5px; + } + } + + &-radio-group.flex { + display: flex !important; + + .ant-radio-button-wrapper { + flex: 1 1 0%; + } + } +} + +.content { + content: ''; + display: inline-block; + width: 8px; +} + +.before { + &\:mark:before { + @apply content; + width: 4px; + padding: .2rem; + @apply top-4; + //position: absolute; + right: 0; + height: 48px; + border-radius: 5px 0 0 5px; + background: @secondary; + color: @secondary; + } + &\:bullet { + font-size: 13px; + + &:before { + @apply content; + height: 8px; + border-radius: 100%; + margin-left: 10px; + } + } + + &\:bg { + &-secondary:before { + @apply content; + background: @secondary; + } + + &-primary:before { + @apply content; + background: @primary; + } + } + + &\:text { + &-secondary:before { + content: '1'; + color: @secondary; + } + + &-primary:before { + content: '1'; + color: @primary; + } + } +} + +.hide-counter { + .ant-input-number-handler-wrap { + visibility: hidden; + display: none; + } +} +.ant-select{ + width: 100% !important; + .ant-select-selector{ + height: 3rem !important; + align-items: center; + } +} +*:not(input) { + user-select: none; /* supported by Chrome and Opera */ + -webkit-user-select: none; /* Safari */ + -khtml-user-select: none; /* Konqueror HTML */ + -moz-user-select: none; /* Firefox */ + -ms-user-select: none; /* Internet Explorer/Edge */ +} + +.ant-empty-image img,.ant-empty-image{ + width: 100%; + height: 40vh +} diff --git a/apps/user-app/src/assets/styles/tailwind.css b/apps/user-app/src/assets/styles/tailwind.css new file mode 100644 index 0000000..bd6213e --- /dev/null +++ b/apps/user-app/src/assets/styles/tailwind.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/apps/user-app/src/components/AppHeader.vue b/apps/user-app/src/components/AppHeader.vue new file mode 100644 index 0000000..3c9ca95 --- /dev/null +++ b/apps/user-app/src/components/AppHeader.vue @@ -0,0 +1,58 @@ + + + + + + diff --git a/apps/user-app/src/components/DialogVant.vue b/apps/user-app/src/components/DialogVant.vue new file mode 100644 index 0000000..a0f4699 --- /dev/null +++ b/apps/user-app/src/components/DialogVant.vue @@ -0,0 +1,56 @@ + + + + + + diff --git a/apps/user-app/src/components/DialogVantOneButton.vue b/apps/user-app/src/components/DialogVantOneButton.vue new file mode 100644 index 0000000..45770e9 --- /dev/null +++ b/apps/user-app/src/components/DialogVantOneButton.vue @@ -0,0 +1,56 @@ + + + + + + diff --git a/apps/user-app/src/components/EmptyState.vue b/apps/user-app/src/components/EmptyState.vue new file mode 100644 index 0000000..0f58603 --- /dev/null +++ b/apps/user-app/src/components/EmptyState.vue @@ -0,0 +1,32 @@ + + + + + + diff --git a/apps/user-app/src/components/NotifyVant.vue b/apps/user-app/src/components/NotifyVant.vue new file mode 100644 index 0000000..c6d5bf7 --- /dev/null +++ b/apps/user-app/src/components/NotifyVant.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/apps/user-app/src/components/Spinner.vue b/apps/user-app/src/components/Spinner.vue new file mode 100644 index 0000000..d33caec --- /dev/null +++ b/apps/user-app/src/components/Spinner.vue @@ -0,0 +1,124 @@ + + + + + + diff --git a/apps/user-app/src/components/ViewAll.vue b/apps/user-app/src/components/ViewAll.vue new file mode 100644 index 0000000..c3e1d9a --- /dev/null +++ b/apps/user-app/src/components/ViewAll.vue @@ -0,0 +1,19 @@ + + + + diff --git a/apps/user-app/src/components/icon/Icon.vue b/apps/user-app/src/components/icon/Icon.vue new file mode 100644 index 0000000..3a22fd0 --- /dev/null +++ b/apps/user-app/src/components/icon/Icon.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/apps/user-app/src/components/icon/icons.json b/apps/user-app/src/components/icon/icons.json new file mode 100644 index 0000000..21c03b2 --- /dev/null +++ b/apps/user-app/src/components/icon/icons.json @@ -0,0 +1,1027 @@ +{ + "vuesax-linear:24-support": "\n\n\n\n\n", + "vuesax-linear:3d-cube-scan": "\n\n\n\n\n\n\n", + "vuesax-linear:3d-rotate": "\n\n\n\n\n\n\n", + "vuesax-linear:3d-square": "\n\n\n\n\n\n", + "vuesax-linear:3dcube": "\n\n\n\n\n", + "vuesax-linear:3full": "\n\n\n\n\n\n", + "vuesax-linear:3square": "\n\n\n\n\n", + "vuesax-linear:aave-(aave)": "\n\n\n\n\n", + "vuesax-linear:activity": "\n\n\n\n", + "vuesax-linear:add-circle": "\n\n\n\n\n", + "vuesax-linear:add-square": "\n\n\n\n\n", + "vuesax-linear:add": "\n\n\n\n", + "vuesax-linear:additem": "\n\n\n\n\n\n", + "vuesax-linear:airdrop": "\n\n\n\n\n\n", + "vuesax-linear:airplane-square": "\n\n\n\n", + "vuesax-linear:airplane": "\n\n\n", + "vuesax-linear:airpod": "\n\n\n\n\n\n", + "vuesax-linear:airpods": "\n\n\n\n\n\n\n\n", + "vuesax-linear:alarm": "\n\n\n\n\n\n\n", + "vuesax-linear:align-bottom": "\n\n\n\n\n", + "vuesax-linear:align-horizontally": "\n\n\n\n\n\n\n", + "vuesax-linear:align-left": "\n\n\n\n\n", + "vuesax-linear:align-right": "\n\n\n\n\n", + "vuesax-linear:align-vertically": "\n\n\n\n\n\n\n", + "vuesax-linear:android": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:ankr-(ankr)": "\n\n\n\n\n\n", + "vuesax-linear:apple": "\n\n\n", + "vuesax-linear:aquarius": "\n\n\n\n\n", + "vuesax-linear:archive-1": "\n\n\n\n\n", + "vuesax-linear:archive-add": "\n\n\n\n\n", + "vuesax-linear:archive-book": "\n\n\n\n\n\n", + "vuesax-linear:archive-minus": "\n\n\n\n", + "vuesax-linear:archive-slash": "\n\n\n\n\n", + "vuesax-linear:archive-tick": "\n\n\n\n", + "vuesax-linear:archive": "\n\n\n\n\n\n\n\n", + "vuesax-linear:arrange-circle-2": "\n\n\n\n\n\n\n", + "vuesax-linear:arrange-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:arrange-square-2": "\n\n\n\n\n\n\n", + "vuesax-linear:arrange-square": "\n\n\n\n\n\n\n", + "vuesax-linear:arrow-2": "\n\n\n\n\n\n", + "vuesax-linear:arrow-3": "\n\n\n\n\n\n", + "vuesax-linear:arrow-bottom": "\n\n\n", + "vuesax-linear:arrow-circle-down": "\n\n\n\n", + "vuesax-linear:arrow-circle-left": "\n\n\n\n", + "vuesax-linear:arrow-circle-right": "\n\n\n\n", + "vuesax-linear:arrow-down-1": "\n\n\n\n", + "vuesax-linear:arrow-down-2": "\n\n\n\n\n", + "vuesax-linear:arrow-down": "\n\n\n", + "vuesax-linear:arrow-left-1": "\n\n\n", + "vuesax-linear:arrow-left-2": "\n\n\n\n\n", + "vuesax-linear:arrow-left-3": "\n\n\n", + "vuesax-linear:arrow-left": "\n\n\n\n", + "vuesax-linear:arrow-right-1": "\n\n\n", + "vuesax-linear:arrow-right-2": "\n\n\n\n\n", + "vuesax-linear:arrow-right-3": "\n\n\n", + "vuesax-linear:arrow-right": "\n\n\n\n", + "vuesax-linear:arrow-square-down": "\n\n\n\n", + "vuesax-linear:arrow-square-left": "\n\n\n\n", + "vuesax-linear:arrow-square-right": "\n\n\n\n", + "vuesax-linear:arrow-square-up": "\n\n\n\n", + "vuesax-linear:arrow-square": "\n\n\n\n\n\n\n", + "vuesax-linear:arrow-swap-horizontal": "\n\n\n\n\n\n", + "vuesax-linear:arrow-up-1": "\n\n\n", + "vuesax-linear:arrow-up-2": "\n\n\n\n\n", + "vuesax-linear:arrow-up-3": "\n\n\n", + "vuesax-linear:arrow-up": "\n\n\n\n", + "vuesax-linear:arrow": "\n\n\n\n\n\n\n\n", + "vuesax-linear:attach-circle": "\n\n\n\n", + "vuesax-linear:attach-square": "\n\n\n\n", + "vuesax-linear:audio-square": "\n\n\n\n\n\n", + "vuesax-linear:augur-(rep)": "\n\n\n\n\n", + "vuesax-linear:autobrightness": "\n\n\n\n\n", + "vuesax-linear:autonio-(niox)": "\n\n\n\n\n\n", + "vuesax-linear:avalanche-(avax)": "\n\n\n\n\n", + "vuesax-linear:award": "\n\n\n\n\n", + "vuesax-linear:back-square": "\n\n\n\n\n", + "vuesax-linear:backward-10-seconds": "\n\n\n\n\n\n", + "vuesax-linear:backward-15-seconds": "\n\n\n\n\n\n", + "vuesax-linear:backward-5-seconds": "\n\n\n\n\n", + "vuesax-linear:backward-item-1": "\n\n\n\n\n", + "vuesax-linear:backward-item": "\n\n\n\n\n", + "vuesax-linear:backward": "\n\n\n\n", + "vuesax-linear:bag-2": "\n\n\n\n\n\n", + "vuesax-linear:bag-cross-1": "\n\n\n\n\n\n\n", + "vuesax-linear:bag-cross": "\n\n\n\n\n\n\n\n", + "vuesax-linear:bag-happy": "\n\n\n\n\n\n\n", + "vuesax-linear:bag-tick-2": "\n\n\n\n\n\n", + "vuesax-linear:bag-tick": "\n\n\n\n\n\n\n", + "vuesax-linear:bag-timer": "\n\n\n\n\n\n", + "vuesax-linear:bag": "\n\n\n\n\n\n\n\n", + "vuesax-linear:bank": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:barcode": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:battery-3full": "\n\n\n\n\n\n", + "vuesax-linear:battery-charging": "\n\n\n\n\n\n", + "vuesax-linear:battery-disable": "\n\n\n\n\n\n", + "vuesax-linear:battery-empty-1": "\n\n\n\n\n", + "vuesax-linear:battery-empty": "\n\n\n\n", + "vuesax-linear:battery-full": "\n\n\n\n\n\n\n", + "vuesax-linear:be": "\n\n\n\n\n", + "vuesax-linear:bezier-1": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:bezier": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:bill": "\n\n\n\n\n\n", + "vuesax-linear:binance-coin-(bnb)": "\n\n\n\n\n\n\n", + "vuesax-linear:binance-usd-(busd)": "\n\n\n\n\n\n", + "vuesax-linear:bitcoin-(btc)": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:bitcoin-card": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:bitcoin-convert": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:bitcoin-refresh": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:blend-2": "\n\n\n\n\n\n\n", + "vuesax-linear:blend": "\n\n\n\n", + "vuesax-linear:blogger": "\n\n\n\n\n\n", + "vuesax-linear:bluetooth-2": "\n\n\n\n\n", + "vuesax-linear:bluetooth-circle": "\n\n\n\n", + "vuesax-linear:bluetooth-rectangle": "\n\n\n\n", + "vuesax-linear:bluetooth": "\n\n\n", + "vuesax-linear:blur-1": "\n\n\n\n\n\n\n", + "vuesax-linear:blur": "\n\n\n\n\n\n\n", + "vuesax-linear:book-1": "\n\n\n\n\n\n", + "vuesax-linear:book-saved": "\n\n\n\n\n", + "vuesax-linear:book-square": "\n\n\n\n\n", + "vuesax-linear:book": "\n\n\n\n\n\n", + "vuesax-linear:bookmark-2": "\n\n\n\n", + "vuesax-linear:bookmark": "\n\n\n\n\n\n", + "vuesax-linear:bootsrap": "\n\n\n\n", + "vuesax-linear:box-1": "\n\n\n\n\n", + "vuesax-linear:box-2": "\n\n\n", + "vuesax-linear:box-add": "\n\n\n\n\n\n\n\n", + "vuesax-linear:box-remove": "\n\n\n\n\n\n\n\n", + "vuesax-linear:box-search": "\n\n\n\n\n\n\n", + "vuesax-linear:box-tick": "\n\n\n\n\n\n\n", + "vuesax-linear:box-time": "\n\n\n\n\n\n\n", + "vuesax-linear:box": "\n\n\n\n\n\n", + "vuesax-linear:briefcase": "\n\n\n\n\n\n\n", + "vuesax-linear:brifecase-cross": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:brifecase-tick": "\n\n\n\n\n\n\n\n", + "vuesax-linear:brifecase-timer": "\n\n\n\n\n\n\n\n", + "vuesax-linear:broom": "\n\n\n\n\n\n\n\n", + "vuesax-linear:brush-1": "\n\n\n\n\n\n", + "vuesax-linear:brush-2": "\n\n\n\n\n\n", + "vuesax-linear:brush-3": "\n\n\n\n\n\n\n", + "vuesax-linear:brush": "\n\n\n\n\n", + "vuesax-linear:bubble": "\n\n\n\n\n", + "vuesax-linear:bucket-circle-1": "\n\n\n\n\n\n\n", + "vuesax-linear:bucket-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:bucket-square-1": "\n\n\n\n\n\n\n", + "vuesax-linear:bucket-square": "\n\n\n\n\n\n\n", + "vuesax-linear:building-3": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:building-4": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:building": "\n\n\n\n\n\n\n\n", + "vuesax-linear:buildings-2": "\n\n\n\n\n\n\n\n", + "vuesax-linear:buildings": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:buliding": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:bus": "\n\n\n\n\n\n\n", + "vuesax-linear:buy-crypto": "\n\n\n\n\n", + "vuesax-linear:cake": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calculator": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calendar-1": "\n\n\n\n\n\n\n", + "vuesax-linear:calendar-2": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calendar-add": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calendar-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:calendar-edit": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calendar-remove": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calendar-search": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calendar-tick": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:calendar": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:call-add": "\n\n\n\n\n", + "vuesax-linear:call-calling": "\n\n\n\n\n", + "vuesax-linear:call-incoming": "\n\n\n\n", + "vuesax-linear:call-minus": "\n\n\n\n", + "vuesax-linear:call-outgoing": "\n\n\n\n", + "vuesax-linear:call-received": "\n\n\n\n", + "vuesax-linear:call-remove": "\n\n\n\n\n", + "vuesax-linear:call-slash": "\n\n\n\n\n", + "vuesax-linear:call": "\n\n\n", + "vuesax-linear:camera-slash": "\n\n\n\n\n\n\n", + "vuesax-linear:camera": "\n\n\n\n\n", + "vuesax-linear:candle-2": "\n\n\n\n\n\n\n\n", + "vuesax-linear:candle": "\n\n\n\n\n\n\n\n", + "vuesax-linear:car": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:card-add": "\n\n\n\n\n\n\n\n", + "vuesax-linear:card-coin": "\n\n\n\n\n\n\n", + "vuesax-linear:card-edit": "\n\n\n\n\n\n\n\n", + "vuesax-linear:card-pos": "\n\n\n\n\n\n\n", + "vuesax-linear:card-receive": "\n\n\n\n\n\n\n\n", + "vuesax-linear:card-remove-1": "\n\n\n\n\n\n\n\n", + "vuesax-linear:card-remove": "\n\n\n\n\n\n\n\n", + "vuesax-linear:card-send": "\n\n\n\n\n\n\n\n", + "vuesax-linear:card-slash": "\n\n\n\n\n\n\n\n", + "vuesax-linear:card-tick-1": "\n\n\n\n\n\n\n", + "vuesax-linear:card-tick": "\n\n\n\n\n\n\n", + "vuesax-linear:card": "\n\n\n\n\n\n", + "vuesax-linear:cardano-(ada)": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:cards": "\n\n\n\n\n\n\n", + "vuesax-linear:category-2": "\n\n\n\n\n\n", + "vuesax-linear:category": "\n\n\n\n\n\n", + "vuesax-linear:cd": "\n\n\n\n", + "vuesax-linear:celo-(celo)": "\n\n\n\n", + "vuesax-linear:celsius-(cel)-": "\n\n\n\n\n", + "vuesax-linear:chainlink-(link)": "\n\n\n", + "vuesax-linear:chart-1": "\n\n\n\n\n\n", + "vuesax-linear:chart-2": "\n\n\n\n\n", + "vuesax-linear:chart-21": "\n\n\n\n\n\n", + "vuesax-linear:chart-3": "\n\n\n\n\n", + "vuesax-linear:chart-square": "\n\n\n\n\n\n", + "vuesax-linear:chart-success": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:chart": "\n\n\n\n\n\n", + "vuesax-linear:check": "\n\n\n\n\n\n", + "vuesax-linear:chrome": "\n\n\n\n\n\n\n", + "vuesax-linear:civic-(cvc)": "\n\n\n\n", + "vuesax-linear:clipboard-close": "\n\n\n\n\n\n", + "vuesax-linear:clipboard-export": "\n\n\n\n\n\n\n", + "vuesax-linear:clipboard-import": "\n\n\n\n\n\n\n", + "vuesax-linear:clipboard-text": "\n\n\n\n\n\n", + "vuesax-linear:clipboard-tick": "\n\n\n\n\n", + "vuesax-linear:clipboard": "\n\n\n\n\n", + "vuesax-linear:clock-1": "\n\n\n\n", + "vuesax-linear:clock": "\n\n\n\n\n\n", + "vuesax-linear:close-circle": "\n\n\n\n\n", + "vuesax-linear:close-square": "\n\n\n\n\n", + "vuesax-linear:cloud-add": "\n\n\n\n\n\n", + "vuesax-linear:cloud-change": "\n\n\n\n\n", + "vuesax-linear:cloud-connection": "\n\n\n\n\n\n\n", + "vuesax-linear:cloud-cross": "\n\n\n\n\n\n\n", + "vuesax-linear:cloud-drizzle": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:cloud-fog": "\n\n\n\n\n\n\n", + "vuesax-linear:cloud-lightning": "\n\n\n\n\n\n", + "vuesax-linear:cloud-minus": "\n\n\n\n\n\n", + "vuesax-linear:cloud-notif": "\n\n\n\n\n", + "vuesax-linear:cloud-plus": "\n\n\n\n\n\n\n", + "vuesax-linear:cloud-remove": "\n\n\n\n\n\n\n", + "vuesax-linear:cloud-snow": "\n\n\n\n\n\n\n\n", + "vuesax-linear:cloud-sunny": "\n\n\n\n\n\n", + "vuesax-linear:cloud": "\n\n\n\n", + "vuesax-linear:code-1": "\n\n\n\n\n", + "vuesax-linear:code-circle": "\n\n\n\n\n\n", + "vuesax-linear:code": "\n\n\n\n\n\n", + "vuesax-linear:coffee": "\n\n\n\n\n\n\n\n", + "vuesax-linear:coin-1": "\n\n\n\n\n\n\n", + "vuesax-linear:coin": "\n\n\n\n\n", + "vuesax-linear:color-swatch": "\n\n\n\n\n\n\n", + "vuesax-linear:colorfilter": "\n\n\n\n\n", + "vuesax-linear:colors-square-1": "\n\n\n\n\n\n", + "vuesax-linear:colors-square": "\n\n\n\n\n\n", + "vuesax-linear:command-square": "\n\n\n\n\n\n\n\n", + "vuesax-linear:command": "\n\n\n\n\n\n\n", + "vuesax-linear:component-1": "\n\n\n", + "vuesax-linear:component": "\n\n\n", + "vuesax-linear:computing": "\n\n\n\n\n\n\n", + "vuesax-linear:convert-3d-cube": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:convert": "\n\n\n\n\n", + "vuesax-linear:convertshape-2": "\n\n\n\n\n\n", + "vuesax-linear:convertshape": "\n\n\n\n\n\n", + "vuesax-linear:copy-success": "\n\n\n\n\n", + "vuesax-linear:copy": "\n\n\n\n", + "vuesax-linear:copyright": "\n\n\n\n", + "vuesax-linear:courthouse": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:cpu-charge": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:cpu-setting": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:cpu": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:creative-commons": "\n\n\n\n\n", + "vuesax-linear:crop": "\n\n\n\n\n\n\n", + "vuesax-linear:crown-1": "\n\n\n\n\n", + "vuesax-linear:crown": "\n\n\n", + "vuesax-linear:cup": "\n\n\n\n\n\n\n\n", + "vuesax-linear:dai-(dai)": "\n\n\n\n\n\n", + "vuesax-linear:danger": "\n\n\n\n\n", + "vuesax-linear:dash-(dash)": "\n\n\n\n", + "vuesax-linear:data-2": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:data": "\n\n\n\n\n\n\n\n", + "vuesax-linear:decred-(dcr)": "\n\n\n\n", + "vuesax-linear:dent-(dent)": "\n\n\n\n\n\n", + "vuesax-linear:designtools": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:device-message": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:devices-1": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:devices": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:diagram": "\n\n\n\n", + "vuesax-linear:diamonds": "\n\n\n\n", + "vuesax-linear:direct-down": "\n\n\n", + "vuesax-linear:direct-inbox": "\n\n\n\n\n\n", + "vuesax-linear:direct-left": "\n\n\n", + "vuesax-linear:direct-normal": "\n\n\n\n", + "vuesax-linear:direct-notification": "\n\n\n\n\n", + "vuesax-linear:direct-right": "\n\n\n", + "vuesax-linear:direct-send": "\n\n\n\n\n\n", + "vuesax-linear:direct-up": "\n\n\n", + "vuesax-linear:direct": "\n\n\n\n\n\n", + "vuesax-linear:directbox-default": "\n\n\n\n", + "vuesax-linear:directbox-notif": "\n\n\n\n\n\n", + "vuesax-linear:directbox-receive": "\n\n\n\n\n\n\n", + "vuesax-linear:directbox-send": "\n\n\n\n\n\n\n", + "vuesax-linear:discount-circle": "\n\n\n\n\n\n", + "vuesax-linear:discount-shape": "\n\n\n\n\n\n", + "vuesax-linear:discover-1": "\n\n\n\n", + "vuesax-linear:discover": "\n\n\n\n", + "vuesax-linear:dislike": "\n\n\n\n", + "vuesax-linear:document-1": "\n\n\n\n", + "vuesax-linear:document-cloud": "\n\n\n\n\n\n", + "vuesax-linear:document-code-2": "\n\n\n\n\n\n", + "vuesax-linear:document-code": "\n\n\n\n\n\n", + "vuesax-linear:document-copy": "\n\n\n\n\n\n\n\n", + "vuesax-linear:document-download": "\n\n\n\n\n\n", + "vuesax-linear:document-favorite": "\n\n\n\n\n", + "vuesax-linear:document-filter": "\n\n\n\n\n\n", + "vuesax-linear:document-forward": "\n\n\n\n\n", + "vuesax-linear:document-like": "\n\n\n\n\n", + "vuesax-linear:document-normal": "\n\n\n\n", + "vuesax-linear:document-previous": "\n\n\n\n\n\n", + "vuesax-linear:document-sketch": "\n\n\n\n\n", + "vuesax-linear:document-text-1": "\n\n\n\n\n\n", + "vuesax-linear:document-text": "\n\n\n\n\n\n", + "vuesax-linear:document-upload": "\n\n\n\n\n\n", + "vuesax-linear:document": "\n\n\n\n\n", + "vuesax-linear:dollar-circle": "\n\n\n\n\n", + "vuesax-linear:dollar-square": "\n\n\n\n\n", + "vuesax-linear:dribbble": "\n\n\n\n\n\n", + "vuesax-linear:driver-2": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:driver-refresh": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:driver": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:driving": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:drop": "\n\n\n", + "vuesax-linear:dropbox": "\n\n\n\n\n\n\n", + "vuesax-linear:edit-2": "\n\n\n\n\n", + "vuesax-linear:edit": "\n\n\n\n\n", + "vuesax-linear:educare-(ekt)": "\n\n\n\n\n\n", + "vuesax-linear:electricity": "\n\n\n\n\n\n", + "vuesax-linear:element-1": "\n\n\n\n\n", + "vuesax-linear:element-2": "\n\n\n\n\n", + "vuesax-linear:element-3": "\n\n\n\n\n\n", + "vuesax-linear:element-4": "\n\n\n\n\n\n", + "vuesax-linear:element-equal": "\n\n\n\n\n\n\n", + "vuesax-linear:element-plus": "\n\n\n\n\n\n\n", + "vuesax-linear:emercoin-(emc)": "\n\n\n\n\n", + "vuesax-linear:emoji-happy": "\n\n\n\n\n\n", + "vuesax-linear:emoji-normal": "\n\n\n\n\n\n", + "vuesax-linear:emoji-sad": "\n\n\n\n\n\n", + "vuesax-linear:empty-wallet-add": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:empty-wallet-change": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:empty-wallet-remove": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:empty-wallet-tick": "\n\n\n\n\n\n\n\n", + "vuesax-linear:empty-wallet-time": "\n\n\n\n\n\n\n\n", + "vuesax-linear:empty-wallet": "\n\n\n\n\n\n", + "vuesax-linear:enjin-coin-(enj)": "\n\n\n\n\n", + "vuesax-linear:eos-(eos)": "\n\n\n\n", + "vuesax-linear:eraser-1": "\n\n\n\n\n\n\n", + "vuesax-linear:eraser-2": "\n\n\n\n\n\n\n", + "vuesax-linear:eraser": "\n\n\n\n\n", + "vuesax-linear:ethereum-(eth)": "\n\n\n\n\n\n", + "vuesax-linear:ethereum-classic-(etc)": "\n\n\n\n\n", + "vuesax-linear:export-1": "\n\n\n\n\n", + "vuesax-linear:export-2": "\n\n\n\n\n", + "vuesax-linear:export-3": "\n\n\n\n\n", + "vuesax-linear:export": "\n\n\n\n\n", + "vuesax-linear:external-drive": "\n\n\n\n\n\n\n\n", + "vuesax-linear:eye-slash": "\n\n\n\n\n\n\n\n", + "vuesax-linear:eye": "\n\n\n\n", + "vuesax-linear:facebook": "\n\n\n\n", + "vuesax-linear:fatrows": "\n\n\n\n", + "vuesax-linear:favorite-chart": "\n\n\n\n\n", + "vuesax-linear:figma-1": "\n\n\n\n\n\n\n\n", + "vuesax-linear:figma": "\n\n\n\n\n\n\n", + "vuesax-linear:filter-add": "\n\n\n\n\n\n", + "vuesax-linear:filter-edit": "\n\n\n\n\n", + "vuesax-linear:filter-remove": "\n\n\n\n\n\n\n", + "vuesax-linear:filter-search": "\n\n\n\n\n", + "vuesax-linear:filter-square": "\n\n\n\n", + "vuesax-linear:filter-tick": "\n\n\n\n\n", + "vuesax-linear:filter": "\n\n\n\n", + "vuesax-linear:finger-cricle": "\n\n\n\n\n", + "vuesax-linear:finger-scan": "\n\n\n\n\n\n\n\n", + "vuesax-linear:firstline": "\n\n\n\n\n\n\n", + "vuesax-linear:flag-2": "\n\n\n\n", + "vuesax-linear:flag": "\n\n\n\n", + "vuesax-linear:flash-1": "\n\n\n", + "vuesax-linear:flash-circle-1": "\n\n\n\n", + "vuesax-linear:flash-circle-2": "\n\n\n\n", + "vuesax-linear:flash-circle": "\n\n\n\n", + "vuesax-linear:flash-slash": "\n\n\n\n\n", + "vuesax-linear:flash": "\n\n\n\n\n\n", + "vuesax-linear:folder-2": "\n\n\n\n", + "vuesax-linear:folder-add": "\n\n\n\n\n", + "vuesax-linear:folder-cloud": "\n\n\n\n", + "vuesax-linear:folder-connection": "\n\n\n\n\n\n\n", + "vuesax-linear:folder-cross": "\n\n\n\n\n", + "vuesax-linear:folder-favorite": "\n\n\n\n", + "vuesax-linear:folder-minus": "\n\n\n\n", + "vuesax-linear:folder-open": "\n\n\n\n\n", + "vuesax-linear:folder": "\n\n\n", + "vuesax-linear:format-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:format-square": "\n\n\n\n\n\n\n", + "vuesax-linear:forward-10-seconds": "\n\n\n\n\n\n", + "vuesax-linear:forward-15-seconds": "\n\n\n\n\n\n", + "vuesax-linear:forward-5-seconds": "\n\n\n\n\n", + "vuesax-linear:forward-item-1": "\n\n\n\n\n", + "vuesax-linear:forward-item": "\n\n\n\n\n", + "vuesax-linear:forward-square": "\n\n\n\n\n", + "vuesax-linear:forward": "\n\n\n\n", + "vuesax-linear:frame-1": "\n\n\n\n\n\n", + "vuesax-linear:frame-2": "\n\n\n\n\n", + "vuesax-linear:frame-3": "\n\n\n\n\n\n\n\n", + "vuesax-linear:frame-4": "\n\n\n\n\n", + "vuesax-linear:frame-5": "\n\n\n\n\n", + "vuesax-linear:frame": "\n\n\n\n", + "vuesax-linear:framer": "\n\n\n\n", + "vuesax-linear:ftx-token-(ftt)": "\n\n\n\n\n\n", + "vuesax-linear:gallery-add": "\n\n\n\n\n\n\n", + "vuesax-linear:gallery-edit": "\n\n\n\n\n\n\n", + "vuesax-linear:gallery-export": "\n\n\n\n\n\n\n", + "vuesax-linear:gallery-favorite": "\n\n\n\n\n\n", + "vuesax-linear:gallery-import": "\n\n\n\n\n\n\n", + "vuesax-linear:gallery-remove": "\n\n\n\n\n\n\n", + "vuesax-linear:gallery-slash": "\n\n\n\n\n\n\n", + "vuesax-linear:gallery-tick": "\n\n\n\n\n\n", + "vuesax-linear:gallery": "\n\n\n\n\n", + "vuesax-linear:game": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:gameboy": "\n\n\n\n\n\n\n\n", + "vuesax-linear:gas-station": "\n\n\n\n\n\n\n", + "vuesax-linear:gemini-2": "\n\n\n\n\n\n\n\n", + "vuesax-linear:gemini": "\n\n\n\n\n\n", + "vuesax-linear:ghost": "\n\n\n\n\n", + "vuesax-linear:gift": "\n\n\n\n\n\n\n", + "vuesax-linear:glass-1": "\n\n\n\n\n\n\n", + "vuesax-linear:glass": "\n\n\n\n", + "vuesax-linear:global-edit": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:global-refresh": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:global-search": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:global": "\n\n\n\n\n\n\n", + "vuesax-linear:google-1": "\n\n\n", + "vuesax-linear:google-play": "\n\n\n\n\n\n", + "vuesax-linear:google": "\n\n\n\n\n\n", + "vuesax-linear:gps-slash": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:gps": "\n\n\n\n\n\n\n\n", + "vuesax-linear:grammerly": "\n\n\n\n", + "vuesax-linear:graph": "\n\n\n\n", + "vuesax-linear:grid-1": "\n\n\n\n\n\n\n", + "vuesax-linear:grid-2": "\n\n\n\n\n", + "vuesax-linear:grid-3": "\n\n\n\n\n\n", + "vuesax-linear:grid-4": "\n\n\n\n", + "vuesax-linear:grid-5": "\n\n\n\n\n", + "vuesax-linear:grid-6": "\n\n\n\n\n\n", + "vuesax-linear:grid-7": "\n\n\n\n\n", + "vuesax-linear:grid-8": "\n\n\n\n\n\n\n\n", + "vuesax-linear:grid-9": "\n\n\n\n", + "vuesax-linear:grid-edit": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:grid-eraser": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:grid-lock": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:group-1": "\n\n\n\n\n\n\n\n", + "vuesax-linear:group-10": "\n\n\n\n", + "vuesax-linear:group-11": "\n\n\n\n\n\n\n", + "vuesax-linear:group-2": "\n\n\n\n\n\n\n", + "vuesax-linear:group-3": "\n\n\n\n", + "vuesax-linear:group-4": "\n\n\n\n\n\n\n", + "vuesax-linear:group-5": "\n\n\n\n", + "vuesax-linear:group-6": "\n\n\n\n\n\n\n", + "vuesax-linear:group-7": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:group-8": "\n\n\n\n", + "vuesax-linear:group-9": "\n\n\n\n\n", + "vuesax-linear:group": "\n\n\n\n\n\n", + "vuesax-linear:happyemoji": "\n\n\n\n", + "vuesax-linear:harmony-(one)": "\n\n\n\n\n\n\n", + "vuesax-linear:hashtag-1": "\n\n\n\n\n\n", + "vuesax-linear:hashtag-down": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:hashtag-up": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:hashtag": "\n\n\n\n\n\n", + "vuesax-linear:headphone": "\n\n\n", + "vuesax-linear:headphones": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:health": "\n\n\n\n", + "vuesax-linear:heart-add": "\n\n\n\n\n\n", + "vuesax-linear:heart-circle": "\n\n\n\n", + "vuesax-linear:heart-edit": "\n\n\n\n\n", + "vuesax-linear:heart-remove": "\n\n\n\n\n\n", + "vuesax-linear:heart-search": "\n\n\n\n\n", + "vuesax-linear:heart-slash": "\n\n\n\n\n", + "vuesax-linear:heart-tick": "\n\n\n\n\n", + "vuesax-linear:heart": " \n\n\n", + "vuesax-linear:hedera-hashgraph-(hbar)": "\n\n\n\n\n\n\n", + "vuesax-linear:hex-(hex)": "\n\n\n\n\n", + "vuesax-linear:hierarchy-2": "\n\n\n\n\n\n\n", + "vuesax-linear:hierarchy-3": "\n\n\n\n\n\n", + "vuesax-linear:hierarchy-square-2": "\n\n\n\n\n\n\n\n", + "vuesax-linear:hierarchy-square-3": "\n\n\n\n\n\n\n\n", + "vuesax-linear:hierarchy-square": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:hierarchy": "\n\n\n\n\n\n\n", + "vuesax-linear:home-2": "\n\n\n\n", + "vuesax-linear:home-hashtag": "\n\n\n\n\n\n", + "vuesax-linear:home-trend-down": "\n\n\n\n\n", + "vuesax-linear:home-trend-up": "\n\n\n\n\n", + "vuesax-linear:home-wifi": "\n\n\n\n\n\n", + "vuesax-linear:home": "\n\n\n\n", + "vuesax-linear:hospital": "\n\n\n\n\n\n\n", + "vuesax-linear:house-2": "\n\n\n\n\n\n\n", + "vuesax-linear:house": "\n\n\n\n\n\n\n\n", + "vuesax-linear:html-3": "\n\n\n\n\n", + "vuesax-linear:html-5": "\n\n\n\n", + "vuesax-linear:huobi-token-(ht)": "\n\n\n\n", + "vuesax-linear:icon-(icx)": "\n\n\n\n\n\n", + "vuesax-linear:illustrator": "\n\n\n\n\n\n\n", + "vuesax-linear:image": "\n\n\n\n", + "vuesax-linear:import-1": "\n\n\n\n\n", + "vuesax-linear:import-2": "\n\n\n\n\n", + "vuesax-linear:import": "\n\n\n\n\n", + "vuesax-linear:info-circle": "\n\n\n\n\n", + "vuesax-linear:information": "\n\n\n\n\n", + "vuesax-linear:instagram": "\n\n\n\n\n", + "vuesax-linear:iost-(iost)": "\n\n\n\n\n\n", + "vuesax-linear:java-script": "\n\n\n\n\n", + "vuesax-linear:js": "\n\n\n\n", + "vuesax-linear:judge": "\n\n\n\n\n\n", + "vuesax-linear:kanban": "\n\n\n\n", + "vuesax-linear:key-square": "\n\n\n\n\n\n", + "vuesax-linear:key": "\n\n\n\n\n", + "vuesax-linear:keyboard-open": "\n\n\n\n\n\n\n\n", + "vuesax-linear:keyboard": "\n\n\n\n\n\n\n", + "vuesax-linear:kyber-network-(knc)": "\n\n\n\n\n", + "vuesax-linear:lamp-1": "\n\n\n\n\n", + "vuesax-linear:lamp-charge": "\n\n\n\n\n", + "vuesax-linear:lamp-on": "\n\n\n\n", + "vuesax-linear:lamp-slash": "\n\n\n\n\n\n", + "vuesax-linear:lamp": "\n\n\n\n\n", + "vuesax-linear:language-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:language-square": "\n\n\n\n\n\n\n", + "vuesax-linear:layer": "\n\n\n\n\n", + "vuesax-linear:level": "\n\n\n\n\n", + "vuesax-linear:lifebuoy": "\n\n\n\n\n\n\n\n", + "vuesax-linear:like-1": "\n\n\n\n", + "vuesax-linear:like-dislike": "\n\n\n\n\n\n", + "vuesax-linear:like-shapes": "\n\n\n\n\n", + "vuesax-linear:like-tag": "\n\n\n\n\n", + "vuesax-linear:like": "\n\n\n\n", + "vuesax-linear:link-1": "\n\n\n\n\n", + "vuesax-linear:link-2": "\n\n\n\n", + "vuesax-linear:link-21": "\n\n\n\n", + "vuesax-linear:link-circle": "\n\n\n\n\n", + "vuesax-linear:link-square": "\n\n\n\n\n", + "vuesax-linear:link": "\n\n\n\n", + "vuesax-linear:litecoin(ltc)": "\n\n\n\n\n", + "vuesax-linear:location-add": "\n\n\n\n\n", + "vuesax-linear:location-cross": "\n\n\n\n\n", + "vuesax-linear:location-minus": "\n\n\n\n", + "vuesax-linear:location-slash": "\n\n\n\n\n\n", + "vuesax-linear:location-tick": "\n\n\n\n", + "vuesax-linear:location": "\n\n\n\n", + "vuesax-linear:lock-1": "\n\n\n\n\n", + "vuesax-linear:lock-circle": "\n\n\n\n\n\n", + "vuesax-linear:lock-slash": "\n\n\n\n\n\n", + "vuesax-linear:lock": "\n\n\n\n\n\n\n", + "vuesax-linear:login-1": "\n\n\n\n\n", + "vuesax-linear:login": "\n\n\n\n\n", + "vuesax-linear:logout-1": "\n\n\n\n\n", + "vuesax-linear:logout": "\n\n\n\n\n", + "vuesax-linear:lovely": "\n\n\n\n", + "vuesax-linear:magic-star": "\n\n\n\n", + "vuesax-linear:magicpen": "\n\n\n\n\n\n\n", + "vuesax-linear:main-component-1": "\n\n\n\n\n", + "vuesax-linear:main-component": "\n\n\n\n\n", + "vuesax-linear:maker-(mkr)": "\n\n\n\n\n", + "vuesax-linear:man": "\n\n\n\n\n", + "vuesax-linear:map-1": "\n\n\n\n\n", + "vuesax-linear:map": "\n\n\n\n\n\n\n", + "vuesax-linear:mask-1": "\n\n\n\n\n\n\n", + "vuesax-linear:mask-2": "\n\n\n\n\n\n\n", + "vuesax-linear:mask-3": "\n\n\n\n", + "vuesax-linear:mask": "\n\n\n\n\n\n\n", + "vuesax-linear:math": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:maximize-1": "\n\n\n\n\n\n", + "vuesax-linear:maximize-2": "\n\n\n\n\n\n\n\n", + "vuesax-linear:maximize-21": "\n\n\n\n\n\n", + "vuesax-linear:maximize-3": "\n\n\n\n\n\n", + "vuesax-linear:maximize-4": "\n\n\n\n\n\n", + "vuesax-linear:maximize-circle": "\n\n\n\n\n\n\n\n", + "vuesax-linear:maximize": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:medal-star": "\n\n\n\n\n", + "vuesax-linear:medal": "\n\n\n\n", + "vuesax-linear:menu-1": "\n\n\n\n\n\n", + "vuesax-linear:menu-board": "\n\n\n\n\n\n\n\n", + "vuesax-linear:menu": "\n\n\n\n\n", + "vuesax-linear:message-2": "\n\n\n\n", + "vuesax-linear:message-add-1": "\n\n\n\n\n", + "vuesax-linear:message-add": "\n\n\n\n\n\n\n", + "vuesax-linear:message-circle": "\n\n\n\n", + "vuesax-linear:message-edit": "\n\n\n\n\n", + "vuesax-linear:message-favorite": "\n\n\n\n\n\n\n", + "vuesax-linear:message-minus": "\n\n\n\n", + "vuesax-linear:message-notif": "\n\n\n\n\n\n\n", + "vuesax-linear:message-programming": "\n\n\n\n\n\n", + "vuesax-linear:message-question": "\n\n\n\n\n", + "vuesax-linear:message-remove": "\n\n\n\n\n\n\n", + "vuesax-linear:message-search": "\n\n\n\n\n\n\n\n", + "vuesax-linear:message-square": "\n\n\n\n", + "vuesax-linear:message-text-1": "\n\n\n\n\n", + "vuesax-linear:message-text": "\n\n\n\n\n", + "vuesax-linear:message-tick": "\n\n\n\n\n\n", + "vuesax-linear:message-time": "\n\n\n\n\n\n", + "vuesax-linear:message": "\n\n\n\n\n\n", + "vuesax-linear:messages-1": "\n\n\n\n\n\n\n", + "vuesax-linear:messages-2": "\n\n\n\n", + "vuesax-linear:messages-3": "\n\n\n\n", + "vuesax-linear:messages": "\n\n\n\n\n", + "vuesax-linear:messenger": "\n\n\n\n", + "vuesax-linear:microphone-2": "\n\n\n\n\n\n\n", + "vuesax-linear:microphone-slash-1": "\n\n\n\n\n\n\n\n", + "vuesax-linear:microphone-slash": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:microphone": "\n\n\n\n\n\n", + "vuesax-linear:microscope": "\n\n\n\n\n\n\n", + "vuesax-linear:milk": "\n\n\n\n\n\n\n", + "vuesax-linear:mini-music-sqaure": "\n\n\n\n\n\n\n", + "vuesax-linear:minus-cirlce": "\n\n\n\n", + "vuesax-linear:minus-square": "\n\n\n\n", + "vuesax-linear:minus": "\n\n\n", + "vuesax-linear:mirror": "\n\n\n\n", + "vuesax-linear:mirroring-screen": "\n\n\n\n\n\n", + "vuesax-linear:mobile-programming": "\n\n\n\n\n\n", + "vuesax-linear:mobile": "\n\n\n\n\n", + "vuesax-linear:monero-(xmr)": "\n\n\n\n", + "vuesax-linear:money-2": "\n\n\n\n\n\n\n\n", + "vuesax-linear:money-3": "\n\n\n\n\n\n", + "vuesax-linear:money-4": "\n\n\n\n\n\n\n\n", + "vuesax-linear:money-add": "\n\n\n\n\n\n\n\n", + "vuesax-linear:money-change": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:money-forbidden": "\n\n\n\n\n\n\n", + "vuesax-linear:money-recive": "\n\n\n\n\n\n\n", + "vuesax-linear:money-remove": "\n\n\n\n\n\n\n\n", + "vuesax-linear:money-send": "\n\n\n\n\n\n\n", + "vuesax-linear:money-tick": "\n\n\n\n\n\n\n", + "vuesax-linear:money-time": "\n\n\n\n\n\n\n", + "vuesax-linear:money": "\n\n\n\n\n\n", + "vuesax-linear:moneys": "\n\n\n\n\n\n\n", + "vuesax-linear:monitor-mobbile": "\n\n\n\n\n\n\n\n", + "vuesax-linear:monitor-recorder": "\n\n\n\n\n\n\n\n", + "vuesax-linear:monitor": "\n\n\n\n\n\n", + "vuesax-linear:moon": "\n\n\n", + "vuesax-linear:more-2": "\n\n\n\n\n", + "vuesax-linear:more-circle": "\n\n\n\n\n\n", + "vuesax-linear:more-square": "\n\n\n\n\n\n", + "vuesax-linear:more": "\n\n\n\n\n", + "vuesax-linear:mouse-1": "\n\n\n\n", + "vuesax-linear:mouse-circle": "\n\n\n\n", + "vuesax-linear:mouse-square": "\n\n\n\n", + "vuesax-linear:mouse": "\n\n\n\n\n", + "vuesax-linear:music-circle": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:music-dashboard": "\n\n\n\n\n\n\n\n", + "vuesax-linear:music-filter": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:music-library-2": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:music-play": "\n\n\n\n", + "vuesax-linear:music-playlist": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:music-square-add": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:music-square-remove": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:music-square-search": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:music-square": "\n\n\n\n\n\n\n", + "vuesax-linear:music": "\n\n\n\n\n\n", + "vuesax-linear:musicnote": "\n\n\n\n\n", + "vuesax-linear:nebulas-(nas)": "\n\n\n\n\n\n", + "vuesax-linear:nem-(xem)": "\n\n\n\n\n", + "vuesax-linear:nexo-(nexo)": "\n\n\n\n", + "vuesax-linear:next": "\n\n\n\n", + "vuesax-linear:note-1": "\n\n\n\n\n\n\n", + "vuesax-linear:note-2": "\n\n\n\n\n\n", + "vuesax-linear:note-21": "\n\n\n\n", + "vuesax-linear:note-add": "\n\n\n\n\n\n\n", + "vuesax-linear:note-favorite": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:note-remove": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:note-square": "\n\n\n\n\n\n", + "vuesax-linear:note-text": "\n\n\n\n\n\n\n", + "vuesax-linear:note": "\n\n\n\n\n\n", + "vuesax-linear:notification-1": "\n\n\n\n", + "vuesax-linear:notification-bing": "\n\n\n\n\n", + "vuesax-linear:notification-circle": "\n\n\n\n", + "vuesax-linear:notification-favorite": "\n\n\n\n\n", + "vuesax-linear:notification-status": "\n\n\n\n\n\n", + "vuesax-linear:notification": "\n\n\n\n\n", + "vuesax-linear:ocean-protocol-(ocean)": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:okb-(okb)": "\n\n\n\n\n\n", + "vuesax-linear:omega-circle-1": "\n\n\n\n", + "vuesax-linear:omega-circle": "\n\n\n\n", + "vuesax-linear:omega-square-1": "\n\n\n\n", + "vuesax-linear:omega-square": "\n\n\n\n", + "vuesax-linear:ontology-(ont)": "\n\n\n\n", + "vuesax-linear:paintbucket": "\n\n\n\n\n", + "vuesax-linear:paperclip-2": "\n\n\n", + "vuesax-linear:paperclip": "\n\n\n", + "vuesax-linear:password-check": "\n\n\n\n\n\n\n", + "vuesax-linear:path-2": "\n\n\n\n\n", + "vuesax-linear:path-square": "\n\n\n\n\n\n", + "vuesax-linear:path": "\n\n\n\n\n", + "vuesax-linear:pause-circle": "\n\n\n\n\n", + "vuesax-linear:pause": "\n\n\n\n", + "vuesax-linear:paypal": "\n\n\n\n", + "vuesax-linear:pen-add-1": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-add": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-close-1": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-close": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-remove-1": "\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-remove": "\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-tool-1": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-tool-2-1": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-tool-2": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:pen-tool": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:people": "\n\n\n\n\n\n\n\n", + "vuesax-linear:percentage-square": "\n\n\n\n\n\n", + "vuesax-linear:personalcard": "\n\n\n\n\n\n\n\n", + "vuesax-linear:pet": "\n\n\n\n\n\n\n", + "vuesax-linear:pharagraphspacing": "\n\n\n\n\n\n\n", + "vuesax-linear:photoshop": "\n\n\n\n\n", + "vuesax-linear:picture-frame": "\n\n\n\n\n", + "vuesax-linear:play-add": "\n\n\n\n\n\n\n", + "vuesax-linear:play-circle": "\n\n\n\n", + "vuesax-linear:play-cricle": "\n\n\n\n\n\n\n", + "vuesax-linear:play-remove": "\n\n\n\n\n\n\n", + "vuesax-linear:play": "\n\n\n", + "vuesax-linear:polkadot-(dot)": "\n\n\n\n", + "vuesax-linear:polygon-(matic)": "\n\n\n\n", + "vuesax-linear:polyswarm-(nct)": "\n\n\n\n\n\n", + "vuesax-linear:presention-chart": "\n\n\n\n\n\n\n", + "vuesax-linear:previous": "\n\n\n\n", + "vuesax-linear:printer-slash": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:printer": "\n\n\n\n\n\n\n", + "vuesax-linear:profile-2user": "\n\n\n\n\n\n", + "vuesax-linear:profile-add": "\n\n\n\n\n\n", + "vuesax-linear:profile-circle": "\n\n\n\n\n", + "vuesax-linear:profile-delete": "\n\n\n\n\n\n", + "vuesax-linear:profile-remove": "\n\n\n\n\n", + "vuesax-linear:profile-tick": "\n\n\n\n\n", + "vuesax-linear:profile": "\n\n\n\n", + "vuesax-linear:programming-arrow": "\n\n\n\n\n\n\n\n", + "vuesax-linear:programming-arrows": "\n\n\n\n\n\n\n\n", + "vuesax-linear:python": "\n\n\n\n\n\n\n", + "vuesax-linear:quant-(qnt)": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:quote-down-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:quote-down-square": "\n\n\n\n\n\n\n", + "vuesax-linear:quote-down": "\n\n\n\n\n\n", + "vuesax-linear:quote-up-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:quote-up-square": "\n\n\n\n\n\n\n", + "vuesax-linear:quote-up": "\n\n\n\n\n\n", + "vuesax-linear:radar-1": "\n\n\n\n\n\n\n", + "vuesax-linear:radar-2": "\n\n\n\n", + "vuesax-linear:radar": "\n\n\n", + "vuesax-linear:radio": "\n\n\n\n\n\n\n\n", + "vuesax-linear:ram-2": "\n\n\n\n\n\n\n", + "vuesax-linear:ram": "\n\n\n\n\n", + "vuesax-linear:ranking-1": "\n\n\n\n\n\n", + "vuesax-linear:ranking": "\n\n\n\n\n\n", + "vuesax-linear:receipt-1": "\n\n\n\n\n", + "vuesax-linear:receipt-2-1": "\n\n\n\n\n\n\n", + "vuesax-linear:receipt-2": "\n\n\n\n\n", + "vuesax-linear:receipt-add": "\n\n\n\n\n\n", + "vuesax-linear:receipt-discount": "\n\n\n\n\n\n\n", + "vuesax-linear:receipt-disscount": "\n\n\n\n\n\n", + "vuesax-linear:receipt-edit": "\n\n\n\n\n\n\n", + "vuesax-linear:receipt-item": "\n\n\n\n\n\n\n\n", + "vuesax-linear:receipt-minus": "\n\n\n\n\n", + "vuesax-linear:receipt-search": "\n\n\n\n\n\n\n", + "vuesax-linear:receipt-square": "\n\n\n\n", + "vuesax-linear:receipt-text": "\n\n\n\n\n\n", + "vuesax-linear:receipt": "\n\n\n\n", + "vuesax-linear:receive-square-2": "\n\n\n\n\n\n", + "vuesax-linear:receive-square": "\n\n\n\n\n\n", + "vuesax-linear:received": "\n\n\n\n\n", + "vuesax-linear:record-circle": "\n\n\n\n", + "vuesax-linear:record": "\n\n\n", + "vuesax-linear:recovery-convert": "\n\n\n\n\n\n", + "vuesax-linear:redo": "\n\n\n\n", + "vuesax-linear:refresh-2": "\n\n\n", + "vuesax-linear:refresh-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:refresh-left-square": "\n\n\n\n\n\n", + "vuesax-linear:refresh-right-square": "\n\n\n\n\n\n", + "vuesax-linear:refresh-square-2": "\n\n\n\n", + "vuesax-linear:refresh": "\n\n\n\n", + "vuesax-linear:repeat-circle": "\n\n\n\n\n\n\n", + "vuesax-linear:repeat": "\n\n\n\n\n\n", + "vuesax-linear:repeate-music": "\n\n\n\n\n", + "vuesax-linear:repeate-one": "\n\n\n\n\n", + "vuesax-linear:reserve": "\n\n\n\n\n\n", + "vuesax-linear:rotate-left-1": "\n\n\n\n", + "vuesax-linear:rotate-left": "\n\n\n\n\n", + "vuesax-linear:rotate-right-1": "\n\n\n\n", + "vuesax-linear:rotate-right": "\n\n\n\n\n", + "vuesax-linear:route-square": "\n\n\n\n", + "vuesax-linear:routing-2": "\n\n\n\n\n\n\n", + "vuesax-linear:routing": "\n\n\n\n\n\n\n", + "vuesax-linear:row-horizontal": "\n\n\n\n", + "vuesax-linear:row-vertical": "\n\n\n\n", + "vuesax-linear:ruler&pen": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:ruler": "\n\n\n\n\n\n\n", + "vuesax-linear:safe-home": "\n\n\n\n", + "vuesax-linear:sagittarius": "\n\n\n\n\n", + "vuesax-linear:save-2": "\n\n\n\n\n", + "vuesax-linear:save-add": "\n\n\n\n\n\n", + "vuesax-linear:save-minus": "\n\n\n\n\n", + "vuesax-linear:save-remove": "\n\n\n\n\n\n", + "vuesax-linear:scan-barcode": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:scan": "\n\n\n\n\n\n\n\n", + "vuesax-linear:scanner": "\n\n\n\n\n", + "vuesax-linear:scanning": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:scissor-1": "\n\n\n\n\n\n", + "vuesax-linear:scissor-2": "\n\n\n\n\n\n\n", + "vuesax-linear:scissor": "\n\n\n\n\n\n", + "vuesax-linear:screenmirroring": "\n\n\n\n", + "vuesax-linear:scroll": "\n\n\n\n\n", + "vuesax-linear:search-favorite-1": "\n\n\n\n\n", + "vuesax-linear:search-favorite": "\n\n\n\n\n", + "vuesax-linear:search-normal-1": "\n\n\n\n", + "vuesax-linear:search-normal": "\n\n\n\n", + "vuesax-linear:search-status-1": "\n\n\n\n\n\n", + "vuesax-linear:search-status": "\n\n\n\n\n\n", + "vuesax-linear:search-zoom-in-1": "\n\n\n\n\n\n", + "vuesax-linear:search-zoom-in": "\n\n\n\n\n\n", + "vuesax-linear:search-zoom-out-1": "\n\n\n\n\n", + "vuesax-linear:search-zoom-out": "\n\n\n\n\n", + "vuesax-linear:security-card": "\n\n\n\n\n\n\n", + "vuesax-linear:security-safe": "\n\n\n\n\n", + "vuesax-linear:security-time": "\n\n\n\n\n", + "vuesax-linear:security-user": "\n\n\n\n\n", + "vuesax-linear:security": "\n\n\n", + "vuesax-linear:send-1": "\n\n\n\n", + "vuesax-linear:send-2": "\n\n\n\n", + "vuesax-linear:send-sqaure-2": "\n\n\n\n\n\n", + "vuesax-linear:send-square": "\n\n\n\n\n\n", + "vuesax-linear:send": "\n\n\n\n\n", + "vuesax-linear:setting-2": "\n\n\n\n", + "vuesax-linear:setting-3": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:setting-4": "\n\n\n\n\n\n\n\n", + "vuesax-linear:setting-5": "\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:setting": "\n\n\n\n", + "vuesax-linear:settings": "\n\n\n\n\n", + "vuesax-linear:shapes-1": "\n\n\n\n", + "vuesax-linear:shapes-2": "\n\n\n\n", + "vuesax-linear:shapes": "\n\n\n\n", + "vuesax-linear:share": "\n\n\n\n\n\n\n\n", + "vuesax-linear:shield-cross": "\n\n\n\n\n", + "vuesax-linear:shield-search": "\n\n\n\n\n", + "vuesax-linear:shield-security": "\n\n\n\n\n", + "vuesax-linear:shield-slash": "\n\n\n\n\n", + "vuesax-linear:shield-tick": "\n\n\n\n", + "vuesax-linear:shield": "\n\n\n", + "vuesax-linear:ship": "\n\n\n\n\n\n", + "vuesax-linear:shop-add": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:shop-remove": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:shop": "\n\n\n\n\n\n\n", + "vuesax-linear:shopping-bag": "\n\n\n\n\n", + "vuesax-linear:shopping-cart": "\n\n\n\n\n\n", + "vuesax-linear:siacoin-(sc)": "\n\n\n\n\n", + "vuesax-linear:sidebar-bottom": "\n\n\n\n\n", + "vuesax-linear:sidebar-left": "\n\n\n\n\n", + "vuesax-linear:sidebar-right": "\n\n\n\n\n", + "vuesax-linear:sidebar-top": "\n\n\n\n\n", + "vuesax-linear:signpost": "\n\n\n\n\n\n\n", + "vuesax-linear:simcard-1": "\n\n\n\n\n\n", + "vuesax-linear:simcard-2": "\n\n\n\n", + "vuesax-linear:simcard": "\n\n\n\n\n", + "vuesax-linear:size": "\n\n\n\n", + "vuesax-linear:slack": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:slash": "\n\n\n\n", + "vuesax-linear:slider-horizontal-1": "\n\n\n\n\n", + "vuesax-linear:slider-horizontal": "\n\n\n\n\n", + "vuesax-linear:slider-vertical-1": "\n\n\n\n\n", + "vuesax-linear:slider-vertical": "\n\n\n\n\n", + "vuesax-linear:slider": "\n\n\n\n\n\n", + "vuesax-linear:smallcaps": "\n\n\n\n\n\n\n\n", + "vuesax-linear:smart-car": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:smart-home": "\n\n\n\n\n\n", + "vuesax-linear:smileys": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:sms-edit": "\n\n\n\n\n\n", + "vuesax-linear:sms-notification": "\n\n\n\n\n", + "vuesax-linear:sms-search": "\n\n\n\n\n\n", + "vuesax-linear:sms-star": "\n\n\n\n\n", + "vuesax-linear:sms-tracking": "\n\n\n\n\n\n", + "vuesax-linear:sms": "\n\n\n\n", + "vuesax-linear:snapchat": "\n\n\n\n", + "vuesax-linear:solana-(sol)": "\n\n\n\n\n", + "vuesax-linear:sort": "\n\n\n\n\n", + "vuesax-linear:sound": "\n\n\n\n\n\n\n", + "vuesax-linear:speaker": "\n\n\n\n\n", + "vuesax-linear:speedometer": "\n\n\n\n\n", + "vuesax-linear:spotify": "\n\n\n\n\n\n", + "vuesax-linear:stacks-(stx)": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:star-1": "\n\n\n", + "vuesax-linear:star-slash": "\n\n\n\n\n", + "vuesax-linear:star": "\n\n\n\n\n\n", + "vuesax-linear:status-up": "\n\n\n\n\n\n\n\n", + "vuesax-linear:status": "\n\n\n\n\n", + "vuesax-linear:stellar-(xlm)": "\n\n\n\n\n\n", + "vuesax-linear:sticker": "\n\n\n\n", + "vuesax-linear:stickynote": "\n\n\n\n\n\n\n\n", + "vuesax-linear:stop-circle": "\n\n\n\n", + "vuesax-linear:stop": "\n\n\n", + "vuesax-linear:story": "\n\n\n\n\n\n", + "vuesax-linear:strongbox-2": "\n\n\n\n\n\n\n\n", + "vuesax-linear:strongbox": "\n\n\n\n\n\n", + "vuesax-linear:subtitle": "\n\n\n\n\n\n\n", + "vuesax-linear:sun-1": "\n\n\n\n", + "vuesax-linear:sun-fog": "\n\n\n\n\n\n\n", + "vuesax-linear:sun": "\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:tag-2": "\n\n\n\n\n", + "vuesax-linear:tag-cross": "\n\n\n\n\n", + "vuesax-linear:tag-right": "\n\n\n", + "vuesax-linear:tag-user": "\n\n\n\n\n", + "vuesax-linear:tag": "\n\n\n\n", + "vuesax-linear:task-square": "\n\n\n\n\n\n\n", + "vuesax-linear:task": "\n\n\n\n\n\n\n\n", + "vuesax-linear:teacher": "\n\n\n\n\n", + "vuesax-linear:tenx-(pay)": "\n\n\n\n", + "vuesax-linear:tether-(usdt)": "\n\n\n\n\n", + "vuesax-linear:text-block": "\n\n\n\n\n", + "vuesax-linear:text-bold": "\n\n\n\n", + "vuesax-linear:text-italic": "\n\n\n\n\n", + "vuesax-linear:text-underline": "\n\n\n\n", + "vuesax-linear:text": "\n\n\n\n\n", + "vuesax-linear:textalign-center": "\n\n\n\n\n\n", + "vuesax-linear:textalign-justifycenter": "\n\n\n\n\n\n", + "vuesax-linear:textalign-justifyleft": "\n\n\n\n\n\n", + "vuesax-linear:textalign-justifyright": "\n\n\n\n\n\n", + "vuesax-linear:textalign-left": "\n\n\n\n\n\n", + "vuesax-linear:textalign-right": "\n\n\n\n\n\n", + "vuesax-linear:the-graph-(grt)": "\n\n\n\n\n\n", + "vuesax-linear:theta-(theta)": "\n\n\n\n\n\n\n\n", + "vuesax-linear:thorchain-(rune)": "\n\n\n", + "vuesax-linear:tick-circle": "\n\n\n\n", + "vuesax-linear:tick-square": "\n\n\n\n", + "vuesax-linear:ticket-2": "\n\n\n\n\n", + "vuesax-linear:ticket-discount": "\n\n\n\n\n\n", + "vuesax-linear:ticket-expired": "\n\n\n\n\n", + "vuesax-linear:ticket-star": "\n\n\n\n\n\n", + "vuesax-linear:ticket": "\n\n\n\n", + "vuesax-linear:timer-1": "\n\n\n\n\n", + "vuesax-linear:timer-pause": "\n\n\n\n\n\n\n", + "vuesax-linear:timer-start": "\n\n\n\n\n\n", + "vuesax-linear:timer": "\n\n\n", + "vuesax-linear:toggle-off-circle": "\n\n\n\n", + "vuesax-linear:toggle-off": "\n\n\n\n", + "vuesax-linear:toggle-on-circle": "\n\n\n\n", + "vuesax-linear:toggle-on": "\n\n\n\n", + "vuesax-linear:trade": "\n\n\n\n\n\n", + "vuesax-linear:transaction-minus": "\n\n\n\n", + "vuesax-linear:translate": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:trash": "\n\n\n\n\n\n\n", + "vuesax-linear:tree": "\n\n\n\n\n", + "vuesax-linear:trello": "\n\n\n\n\n", + "vuesax-linear:trend-down": "\n\n\n\n\n", + "vuesax-linear:trend-up": "\n\n\n\n\n", + "vuesax-linear:triangle": "\n\n\n\n\n", + "vuesax-linear:trontron-(trx)": "\n\n\n\n\n", + "vuesax-linear:truck-remove": "\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:truck-tick": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:truck-time": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:trush-square": "\n\n\n\n\n\n", + "vuesax-linear:twitch": "\n\n\n\n\n", + "vuesax-linear:ui8": "\n\n\n\n\n\n\n", + "vuesax-linear:undo": "\n\n\n\n", + "vuesax-linear:unlimited": "\n\n\n", + "vuesax-linear:unlock": "\n\n\n\n\n", + "vuesax-linear:usd-coin-(usdc)": "\n\n\n\n\n\n\n", + "vuesax-linear:user-add": "\n\n\n\n\n\n\n", + "vuesax-linear:user-cirlce-add": "\n\n\n\n\n\n\n\n", + "vuesax-linear:user-edit": "\n\n\n\n\n\n", + "vuesax-linear:user-minus": "\n\n\n\n\n\n", + "vuesax-linear:user-octagon": "\n\n\n\n\n", + "vuesax-linear:user-remove": "\n\n\n\n\n\n\n", + "vuesax-linear:user-search": "\n\n\n\n\n\n", + "vuesax-linear:user-square": "\n\n\n\n\n", + "vuesax-linear:user-tag": "\n\n\n\n\n", + "vuesax-linear:user-tick": "\n\n\n\n\n\n", + "vuesax-linear:user": "\n\n\n\n", + "vuesax-linear:velas-(vlx)": "\n\n\n\n", + "vuesax-linear:verify": "\n\n\n\n", + "vuesax-linear:vibe-(vibe)": "\n\n\n\n\n", + "vuesax-linear:video-add": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:video-circle": "\n\n\n\n", + "vuesax-linear:video-horizontal": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:video-octagon": "\n\n\n\n", + "vuesax-linear:video-play": "\n\n\n\n\n\n\n", + "vuesax-linear:video-remove": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:video-slash": "\n\n\n\n\n\n", + "vuesax-linear:video-square": "\n\n\n\n", + "vuesax-linear:video-tick": "\n\n\n\n\n\n\n\n", + "vuesax-linear:video-time": "\n\n\n\n\n\n\n\n", + "vuesax-linear:video-vertical": "\n\n\n\n\n\n\n\n\n\n\n\n", + "vuesax-linear:video": "\n\n\n\n\n", + "vuesax-linear:voice-cricle": "\n\n\n\n\n\n\n\n", + "vuesax-linear:voice-square": "\n\n\n\n\n\n\n\n", + "vuesax-linear:volume-cross": "\n\n\n\n\n", + "vuesax-linear:volume-high": "\n\n\n\n\n", + "vuesax-linear:volume-low-1": "\n\n\n\n", + "vuesax-linear:volume-low": "\n\n\n\n", + "vuesax-linear:volume-mute": "\n\n\n", + "vuesax-linear:volume-slash": "\n\n\n\n\n\n\n", + "vuesax-linear:volume-up": "\n\n\n\n\n", + "vuesax-linear:vuesax": "\n\n\n\n\n", + "vuesax-linear:wallet-1": "\n\n\n\n\n\n\n", + "vuesax-linear:wallet-2": "\n\n\n\n\n", + "vuesax-linear:wallet-3": "\n\n\n\n\n\n", + "vuesax-linear:wallet-add-1": "\n\n\n\n\n\n\n\n\n", + "vuesax-linear:wallet-add": "\n\n\n\n\n\n\n\n", + "vuesax-linear:wallet-check": "\n\n\n\n\n\n\n", + "vuesax-linear:wallet-minus": "\n\n\n\n\n\n\n\n", + "vuesax-linear:wallet-money": "\n\n\n\n\n\n\n", + "vuesax-linear:wallet-remove": "\n\n\n\n\n\n\n\n", + "vuesax-linear:wallet-search": "\n\n\n\n\n\n\n", + "vuesax-linear:wallet": "\n\n\n\n\n", + "vuesax-linear:wanchain-(wan)-1": "\n\n\n", + "vuesax-linear:wanchain-(wan)": "\n\n\n\n\n\n", + "vuesax-linear:warning-2": "\n\n\n\n\n", + "vuesax-linear:watch-status": "\n\n\n\n\n\n\n\n", + "vuesax-linear:watch": "\n\n\n\n\n\n", + "vuesax-linear:weight-1": "\n\n\n\n\n\n\n", + "vuesax-linear:weight": "\n\n\n\n", + "vuesax-linear:whatsapp": "\n\n\n\n", + "vuesax-linear:wifi-square": "\n\n\n\n\n\n", + "vuesax-linear:wifi": "\n\n\n\n\n\n", + "vuesax-linear:wind-2": "\n\n\n\n\n", + "vuesax-linear:wind": "\n\n\n\n\n", + "vuesax-linear:windows": "\n\n\n\n\n\n", + "vuesax-linear:wing-(wing)": "\n\n\n\n\n", + "vuesax-linear:woman": "\n\n\n\n\n", + "vuesax-linear:xd": "\n\n\n\n\n\n", + "vuesax-linear:xiaomi": "\n\n\n\n\n\n", + "vuesax-linear:xrp-(xrp)": "\n\n\n\n", + "vuesax-linear:youtube": "\n\n\n\n", + "vuesax-linear:zel-(zel)": "\n\n\n\n\n\n", + "vuesax-linear:zoom": "\n\n\n\n\n", + "vuesax-linear:number-18": "\n\n\n\n", + "vuesax-linear:number-24": "\n\n\n\n", + "vuesax-linear:heart-fill": "\n\n\n", + "vuesax-linear:usd-coins": "\n\n\n\n\n", + "vuesax-linear:heart-transparent": "\n\n\n", + "vuesax-linear:shield_dollar": "\n\n\n\n\n" + +} \ No newline at end of file diff --git a/apps/user-app/src/components/input/OtpInput.vue b/apps/user-app/src/components/input/OtpInput.vue new file mode 100644 index 0000000..60aefd5 --- /dev/null +++ b/apps/user-app/src/components/input/OtpInput.vue @@ -0,0 +1,138 @@ + + + + diff --git a/apps/user-app/src/components/input/SingleOtpInput.vue b/apps/user-app/src/components/input/SingleOtpInput.vue new file mode 100644 index 0000000..5502202 --- /dev/null +++ b/apps/user-app/src/components/input/SingleOtpInput.vue @@ -0,0 +1,117 @@ + + + + + + diff --git a/apps/user-app/src/components/product/CartItem.vue b/apps/user-app/src/components/product/CartItem.vue new file mode 100644 index 0000000..a4d54ad --- /dev/null +++ b/apps/user-app/src/components/product/CartItem.vue @@ -0,0 +1,82 @@ + + + + + + diff --git a/apps/user-app/src/components/product/Category.vue b/apps/user-app/src/components/product/Category.vue new file mode 100644 index 0000000..901acb1 --- /dev/null +++ b/apps/user-app/src/components/product/Category.vue @@ -0,0 +1,45 @@ + + + + diff --git a/apps/user-app/src/components/product/CollectionCard.vue b/apps/user-app/src/components/product/CollectionCard.vue new file mode 100644 index 0000000..a5a67e5 --- /dev/null +++ b/apps/user-app/src/components/product/CollectionCard.vue @@ -0,0 +1,15 @@ + + + + diff --git a/apps/user-app/src/components/product/OrderCard.vue b/apps/user-app/src/components/product/OrderCard.vue new file mode 100644 index 0000000..880c1be --- /dev/null +++ b/apps/user-app/src/components/product/OrderCard.vue @@ -0,0 +1,49 @@ + + + + diff --git a/apps/user-app/src/components/product/OrderItemCard.vue b/apps/user-app/src/components/product/OrderItemCard.vue new file mode 100644 index 0000000..e73c1bb --- /dev/null +++ b/apps/user-app/src/components/product/OrderItemCard.vue @@ -0,0 +1,46 @@ + + + + diff --git a/apps/user-app/src/components/product/ProductHorizontal.vue b/apps/user-app/src/components/product/ProductHorizontal.vue new file mode 100644 index 0000000..1caa6b5 --- /dev/null +++ b/apps/user-app/src/components/product/ProductHorizontal.vue @@ -0,0 +1,65 @@ + + + + diff --git a/apps/user-app/src/components/product/ProductList.vue b/apps/user-app/src/components/product/ProductList.vue new file mode 100644 index 0000000..12b6576 --- /dev/null +++ b/apps/user-app/src/components/product/ProductList.vue @@ -0,0 +1,44 @@ + + + + diff --git a/apps/user-app/src/components/product/ProductVertical.vue b/apps/user-app/src/components/product/ProductVertical.vue new file mode 100644 index 0000000..3c0c062 --- /dev/null +++ b/apps/user-app/src/components/product/ProductVertical.vue @@ -0,0 +1,75 @@ + + + + diff --git a/apps/user-app/src/components/product/ProductVerticalFull.vue b/apps/user-app/src/components/product/ProductVerticalFull.vue new file mode 100644 index 0000000..475e14f --- /dev/null +++ b/apps/user-app/src/components/product/ProductVerticalFull.vue @@ -0,0 +1,66 @@ + + + + diff --git a/apps/user-app/src/components/product/SingleProduct.vue b/apps/user-app/src/components/product/SingleProduct.vue new file mode 100644 index 0000000..7b92274 --- /dev/null +++ b/apps/user-app/src/components/product/SingleProduct.vue @@ -0,0 +1,242 @@ + + + + diff --git a/apps/user-app/src/components/search/Filters.vue b/apps/user-app/src/components/search/Filters.vue new file mode 100644 index 0000000..f9e21a2 --- /dev/null +++ b/apps/user-app/src/components/search/Filters.vue @@ -0,0 +1,108 @@ + + + + + + diff --git a/apps/user-app/src/components/search/SearchInput.vue b/apps/user-app/src/components/search/SearchInput.vue new file mode 100644 index 0000000..1538459 --- /dev/null +++ b/apps/user-app/src/components/search/SearchInput.vue @@ -0,0 +1,48 @@ + + + + diff --git a/apps/user-app/src/components/slider/HomeSlider.vue b/apps/user-app/src/components/slider/HomeSlider.vue new file mode 100644 index 0000000..5ae700f --- /dev/null +++ b/apps/user-app/src/components/slider/HomeSlider.vue @@ -0,0 +1,146 @@ + + + + + + diff --git a/apps/user-app/src/components/slider/Slider.vue b/apps/user-app/src/components/slider/Slider.vue new file mode 100644 index 0000000..876a7e2 --- /dev/null +++ b/apps/user-app/src/components/slider/Slider.vue @@ -0,0 +1,139 @@ + + + + + + diff --git a/apps/user-app/src/components/ui/Container.vue b/apps/user-app/src/components/ui/Container.vue new file mode 100644 index 0000000..e649690 --- /dev/null +++ b/apps/user-app/src/components/ui/Container.vue @@ -0,0 +1,13 @@ + + + + diff --git a/apps/user-app/src/components/ui/ContentWrapper.vue b/apps/user-app/src/components/ui/ContentWrapper.vue new file mode 100644 index 0000000..231b872 --- /dev/null +++ b/apps/user-app/src/components/ui/ContentWrapper.vue @@ -0,0 +1,12 @@ + + + + diff --git a/apps/user-app/src/components/ui/DropDown.vue b/apps/user-app/src/components/ui/DropDown.vue new file mode 100644 index 0000000..3fc9c29 --- /dev/null +++ b/apps/user-app/src/components/ui/DropDown.vue @@ -0,0 +1,41 @@ + + + + + + diff --git a/apps/user-app/src/components/ui/Navigation.vue b/apps/user-app/src/components/ui/Navigation.vue new file mode 100644 index 0000000..f2a9102 --- /dev/null +++ b/apps/user-app/src/components/ui/Navigation.vue @@ -0,0 +1,155 @@ + + + + + + diff --git a/apps/user-app/src/components/ui/ScrollFetch.vue b/apps/user-app/src/components/ui/ScrollFetch.vue new file mode 100644 index 0000000..7fb7b45 --- /dev/null +++ b/apps/user-app/src/components/ui/ScrollFetch.vue @@ -0,0 +1,41 @@ + + + + diff --git a/apps/user-app/src/components/ui/ScrollFetchAfterSearch.vue b/apps/user-app/src/components/ui/ScrollFetchAfterSearch.vue new file mode 100644 index 0000000..46912d6 --- /dev/null +++ b/apps/user-app/src/components/ui/ScrollFetchAfterSearch.vue @@ -0,0 +1,48 @@ + + + + diff --git a/apps/user-app/src/components/ui/ScrollFetchSearch.vue b/apps/user-app/src/components/ui/ScrollFetchSearch.vue new file mode 100644 index 0000000..483ef2b --- /dev/null +++ b/apps/user-app/src/components/ui/ScrollFetchSearch.vue @@ -0,0 +1,50 @@ + + + + diff --git a/apps/user-app/src/composables/useOrderItemProduct.ts b/apps/user-app/src/composables/useOrderItemProduct.ts new file mode 100644 index 0000000..c5f099c --- /dev/null +++ b/apps/user-app/src/composables/useOrderItemProduct.ts @@ -0,0 +1,39 @@ +// The real GET /customers/:id/orders endpoint returns OrderItem rows with +// only `productVariantId` (no nested product/variant title) — this joins +// against the already-existing GET /catalog/products list client-side rather +// than touching apps/api to add an include. +import { ref } from 'vue' +import { api } from '../api/client' + +export interface VariantLookup { + productTitle: string + variantTitle: string + imageUrl?: string +} + +const cache = ref | null>(null) + +async function ensureLoaded() { + if (cache.value) return cache.value + const products = await api.listProducts() + const map = new Map() + for (const product of products) { + for (const variant of product.variants) { + map.set(variant.id, { + productTitle: product.title, + variantTitle: variant.title, + imageUrl: product.images[0]?.url, + }) + } + } + cache.value = map + return map +} + +export function useOrderItemProduct() { + const lookup = async (productVariantId: string): Promise => { + const map = await ensureLoaded() + return map.get(productVariantId) + } + return { lookup } +} diff --git a/apps/user-app/src/composables/useProduct.ts b/apps/user-app/src/composables/useProduct.ts new file mode 100644 index 0000000..9691efe --- /dev/null +++ b/apps/user-app/src/composables/useProduct.ts @@ -0,0 +1,55 @@ +// Adapts our actual ProductSummary (apps/user-app/src/api/client.ts) into the +// same output shape legacy's Mixins/product/index.ts `useProduct()` produced, +// so the ported product-card components (ProductHorizontal/ProductVertical/ +// ProductVerticalFull) can render unchanged. +// +// Honest gaps vs. legacy (no backend equivalent exists yet — apps/api was not +// touched to add these): +// - promotions/discounts: legacy matched a customer's order against admin +// promotion + ignore-list rules (getCandidPromotions/getPromotion). No such +// data exists in the new API, so `hasDiscountWage` is always null here — +// the discount badge and "before/after" price never render, which is +// correct given there is genuinely nothing to show, not a bug. +// - `wage`: legacy added the logged-in customer's own `percent` (a +// commission/markup field on their account) on top of the variant's +// making-charge %. No login exists in this port yet, so wage is just the +// selected variant's defaultWagePercent on its own. +// - `is_exist` (stock) and `has_warranty`: no such fields exist on our +// ProductVariant model yet — always true / false respectively. +import { computed, ref, unref, type Ref } from 'vue' +import type { ProductSummary, ProductVariantSummary } from '../api/client' + +function getCheapestVariant(variants: ProductVariantSummary[] | undefined): ProductVariantSummary | null { + if (!variants || variants.length === 0) return null + return variants.reduce((cheapest, v) => + parseFloat(v.defaultWagePercent) < parseFloat(cheapest.defaultWagePercent) ? v : cheapest, + ) +} + +export function useProduct(productItem: ProductSummary, initialVariant?: ProductVariantSummary) { + const product = ref(productItem) + const image = computed(() => product.value.images?.[0]?.url) + const id = computed(() => product.value.id) + const title = computed(() => product.value.title) + const variants = computed(() => product.value.variants) + const selectedVariant: Ref = ref(initialVariant ?? getCheapestVariant(unref(variants))) + const min_gram = computed(() => selectedVariant.value ? parseFloat(selectedVariant.value.rashWeightGrams) : 0) + const wage = computed(() => (selectedVariant.value ? parseFloat(selectedVariant.value.defaultWagePercent) : null)) + const hasDiscountWage = ref(null) // no promotions backend yet — see file header + const is_exist = ref(true) // no stock field on ProductVariant yet + const has_warranty = ref(false) // no warranty field on ProductVariant yet + + return { + product, + image, + id, + title, + variants, + selectedVariant, + min_gram, + wage, + hasDiscountWage, + is_exist, + has_warranty, + } +} diff --git a/apps/user-app/src/composables/useProductDetail.ts b/apps/user-app/src/composables/useProductDetail.ts new file mode 100644 index 0000000..2b3386d --- /dev/null +++ b/apps/user-app/src/composables/useProductDetail.ts @@ -0,0 +1,32 @@ +// Adapted from legacy singleProduct.vue's own fetch (GET /product/:id). +// The new API has no single-product-detail endpoint (only GET +// /catalog/products, the flat list) — apps/api is out of scope for this port, +// so this fetches the full list and finds the matching product client-side. +// Wasteful for a large catalog, but honest: no fabricated endpoint, no silent +// behavior change. +import { ref } from 'vue' +import { api, type ProductSummary } from '../api/client' + +export function useProductDetail(id: string) { + const loading = ref(false) + const flag = ref(false) + const notFound = ref(false) + const productRef = ref(null) + + const load = async () => { + loading.value = true + try { + const products = await api.listProducts() + const found = products.find((p) => p.id === id) ?? null + productRef.value = found + notFound.value = !found + flag.value = true + } catch (e) { + console.log(e) + } finally { + loading.value = false + } + } + + return { loading, flag, notFound, productRef, load } +} diff --git a/apps/user-app/src/composables/useProducts.ts b/apps/user-app/src/composables/useProducts.ts new file mode 100644 index 0000000..7893570 --- /dev/null +++ b/apps/user-app/src/composables/useProducts.ts @@ -0,0 +1,38 @@ +// Adapted from legacy henza_pwa/src/Mixins/getProducts.ts. Legacy paginated +// against POST /get-products (page/limit/count). The new API's GET +// /catalog/products returns the full active catalog in one flat response with +// no pagination — so `pull()` (legacy's infinite-scroll "next page" trigger) +// is a no-op after the first successful fetch here, and `totalPages` is +// always 0. This keeps the ScrollFetchSearch infinite-scroll wiring intact +// (it still calls fetch on scroll) without fabricating fake pages. +import { reactive } from 'vue' +import { api, type ProductSummary } from '../api/client' + +export interface ProductsData { + totalPages: number + productList: ProductSummary[] + flag: boolean +} + +export function useProducts() { + const productsData: ProductsData = reactive({ + totalPages: 0, + productList: [], + flag: false, + }) + + const getProducts = async (loading: { value: boolean }, isPull = false) => { + if (isPull) return // no further pages exist server-side yet + loading.value = true + try { + productsData.productList = await api.listProducts() + productsData.flag = productsData.productList.length < 1 + } catch (e) { + console.log(e) + } finally { + loading.value = false + } + } + + return { productsData, getProducts } +} diff --git a/apps/user-app/src/composables/useRefresh.ts b/apps/user-app/src/composables/useRefresh.ts new file mode 100644 index 0000000..6f62268 --- /dev/null +++ b/apps/user-app/src/composables/useRefresh.ts @@ -0,0 +1,17 @@ +// Ported from legacy henza_pwa/src/Mixins/useRefresh.ts. +// Auto-applies a waiting service worker as soon as one is available, so the +// PWA self-updates instead of serving a stale precache indefinitely. +import { unref, watchEffect } from 'vue' +import { useRegisterSW } from 'virtual:pwa-register/vue' + +export function useRefresh() { + const { offlineReady, needRefresh, updateServiceWorker } = useRegisterSW() + + const refreshApp = async () => { + await updateServiceWorker() + offlineReady.value = false + needRefresh.value = false + } + + watchEffect(async () => (unref(offlineReady) || unref(needRefresh)) && (await refreshApp())) +} diff --git a/apps/user-app/src/env.d.ts b/apps/user-app/src/env.d.ts new file mode 100644 index 0000000..291b0f2 --- /dev/null +++ b/apps/user-app/src/env.d.ts @@ -0,0 +1,9 @@ +/// +/// +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent, Record, unknown>; + export default component; +} diff --git a/apps/user-app/src/layout/FullPageLayout.vue b/apps/user-app/src/layout/FullPageLayout.vue new file mode 100644 index 0000000..b4aba39 --- /dev/null +++ b/apps/user-app/src/layout/FullPageLayout.vue @@ -0,0 +1,9 @@ + + + + diff --git a/apps/user-app/src/layout/MasterLayout.vue b/apps/user-app/src/layout/MasterLayout.vue new file mode 100644 index 0000000..a48fdfe --- /dev/null +++ b/apps/user-app/src/layout/MasterLayout.vue @@ -0,0 +1,36 @@ + + + + diff --git a/apps/user-app/src/main.ts b/apps/user-app/src/main.ts new file mode 100644 index 0000000..c8421d8 --- /dev/null +++ b/apps/user-app/src/main.ts @@ -0,0 +1,29 @@ +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import VueLazyLoad from 'vue3-lazyload'; +import App from './App.vue'; +import { router } from './router'; +import Container from './components/ui/Container.vue'; +import Vector from './assets/img/Vector.svg'; + +// Global styles, ported from legacy henza_pwa/src/main.ts — same import order +// (ant-design-vue's own theme first, then our Less overrides on top). Legacy +// imported 'vant/lib/index.css' twice (a dead duplicate) — imported once here. +import './assets/styles/tailwind.css'; +import 'ant-design-vue/dist/antd.less'; +import './assets/styles/style.less'; +import 'vant/lib/index.css'; + +const app = createApp(App); + +app.use(createPinia()); +app.use(router); +// Ported from legacy main.ts:32-46 — Vector.svg is both the while-loading and +// the on-error placeholder, so a missing/failed image paints the brand mark +// instead of collapsing the layout. +app.use(VueLazyLoad, { + loading: Vector, + error: Vector, +}); +app.component('container', Container); +app.mount('#app'); diff --git a/apps/user-app/src/router/index.ts b/apps/user-app/src/router/index.ts new file mode 100644 index 0000000..1338545 --- /dev/null +++ b/apps/user-app/src/router/index.ts @@ -0,0 +1,88 @@ +import { createRouter, createWebHistory } from 'vue-router'; +import { useSessionStore } from '../stores/session'; +import { useNavStore } from '../stores/navStore'; +import { useTransitionStore } from '../stores/transitionStore'; + +// Legacy lazy-loaded every route component (`component: () => import(...)`) so +// the initial bundle only carries the entry route. Matched here. +export const router = createRouter({ + history: createWebHistory(), + // Ported from legacy router/index.ts:225-237 — restore saved scroll on + // back/forward, otherwise jump to top on every navigation. + scrollBehavior(_to, _from, savedPosition) { + if (savedPosition) return savedPosition; + return { top: 0 }; + }, + routes: [ + // legacy's actual home route is /home (name: 'home'), not '/' — matched here. + { path: '/home', name: 'home', component: () => import('../views/HomeView.vue') }, + { + path: '/product/:id', + name: 'singleProduct', + component: () => import('../components/product/SingleProduct.vue'), + props: true, + meta: { layout: 'FullPage' }, + }, + { path: '/newest', name: 'newestProduct', component: () => import('../views/NewestView.vue'), meta: { layout: 'FullPage' } }, + { path: '/cart', name: 'cart', component: () => import('../views/CartView.vue') }, + { path: '/login', name: 'login', component: () => import('../views/user/LoginRegisterView.vue'), meta: { layout: 'FullPage' } }, + { path: '/user', name: 'userPage::hasSubRoute', component: () => import('../views/user/DashboardView.vue') }, + { path: '/orders', name: 'ordersList', component: () => import('../views/user/OrdersView.vue'), meta: { requiresAuth: true } }, + { + path: '/transactions', + name: 'transactionsList', + component: () => import('../views/user/TransactionsView.vue'), + meta: { requiresAuth: true }, + }, + { path: '/profile', name: 'profileData', component: () => import('../views/user/ProfileView.vue'), meta: { requiresAuth: true } }, + { path: '/favorites', name: 'favoritesList', component: () => import('../views/user/FavoritesView.vue'), meta: { requiresAuth: true } }, + { path: '/search', name: 'search', component: () => import('../views/search/SearchView.vue') }, + { path: '/collection/:id?', name: 'collection', component: () => import('../views/search/CollectionView.vue') }, + // legacy redirects any unmatched path (including '/') to the home route by name. + { path: '/', redirect: { name: 'home' } }, + { path: '/:pathMatch(.*)*', redirect: { name: 'home' } }, + ], +}); + +// Ported from legacy's main router guard: redirects an already-logged-in +// user away from /login, and blocks unauthenticated access to routes that +// require a session (legacy blocked ordersList/transactionsList/profileData/ +// favoritesList/completeProfile the same way). +router.beforeEach((to) => { + const session = useSessionStore(); + const nav = useNavStore(); + + if (to.name === 'login' && session.customerId) { + return { name: 'home' }; + } + if (to.meta.requiresAuth && !session.customerId) { + return { name: 'home' }; + } + + // legacy router/index.ts:268-270 — the product page slides down into view. + if (to.name === 'singleProduct') nav.setPgaTransition('slideDown'); + else nav.setPgaTransition(undefined); +}); + +// Ported from legacy router/index.ts:273-286. This hook is what actually +// drives two animations, and its absence was why neither ran: +// 1. cycling transitionFlag false->true replays AppHeader's "shutter" enter +// animation on every navigation; +// 2. setActiveLink moves the bottom-nav GSAP notch + bubble to the current +// tab (it is the ONLY caller of setActiveLink in the whole app). +router.afterEach((to) => { + const nav = useNavStore(); + const transition = useTransitionStore(); + + transition.setTransitionFlag(false); + transition.setTransitionEnded(false); + setTimeout(() => { + transition.setTransitionFlag(true); + }, 100); + + const route = nav.navBarRoutes.find((x) => to.name === x.name); + if (route) { + nav.setActiveLink(route.index); + if (!nav.showNav) nav.setNavVisibility(true); + } else nav.setNavVisibility(false); +}); diff --git a/apps/user-app/src/stores/cartStore.ts b/apps/user-app/src/stores/cartStore.ts new file mode 100644 index 0000000..055f3f0 --- /dev/null +++ b/apps/user-app/src/stores/cartStore.ts @@ -0,0 +1,101 @@ +// Ported from legacy henza_pwa/src/store/cartStore.ts, adapted to our string +// ids and flat (non-paginated) product list. Persisted the same way (a single +// localStorage key), just via Pinia's own state instead of @vueuse's +// useStorage wrapping a hand-rolled class. +import { defineStore } from 'pinia' +import { api, type ProductSummary } from '../api/client' + +export interface CartItem { + pid: string + vid: string + product: ProductSummary + qty: number + note?: string | null +} + +const STORAGE_KEY = 'appCart' + +function loadFromStorage(): CartItem[] { + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? JSON.parse(raw) : [] + } catch { + return [] + } +} + +function persist(items: CartItem[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(items)) +} + +export const useCartStore = defineStore('cart', { + state: () => ({ + items: loadFromStorage() as CartItem[], + }), + actions: { + // Re-syncs cart entries against fresh product data (e.g. price/weight + // changes since the item was added), dropping entries whose + // product/variant no longer exists — same intent as legacy's init(). + async init() { + try { + const products = await api.listProducts() + const refreshed: CartItem[] = [] + for (const item of this.items) { + const product = products.find((p) => p.id === item.pid) + const variant = product?.variants.find((v) => v.id === item.vid) + if (product && variant) { + refreshed.push({ ...item, product }) + } + } + this.items = refreshed + persist(this.items) + } catch (e) { + console.log(e) + } + }, + addToCart(data: CartItem) { + const index = this.find_index(data) + if (index === -1) this.items.push(data) + else { + this.items[index].product = data.product + this.items[index].qty = data.qty + } + persist(this.items) + }, + remove(item: CartItem) { + const index = this.find_index(item) + if (index > -1) this.items.splice(index, 1) + persist(this.items) + }, + // Legacy's exact name (typo'd "Feild"), kept for consistency with the rest + // of this faithfully-ported store's method names. + updateFeild(value: unknown, index: number | null, item: Pick, field: keyof CartItem) { + if (!this.isInCart(item)) return false + const i = index ?? this.find_index(item) + ;(this.items[i] as any)[field] = value + persist(this.items) + return true + }, + find_index(item: Pick) { + return this.items.findIndex((cartItem) => cartItem.pid === item.pid && cartItem.vid === item.vid) + }, + isInCart(item: Pick) { + return this.find_index(item) > -1 + }, + variant(item: CartItem) { + return item.product.variants.find((v) => v.id === item.vid) + }, + total() { + return this.items + .reduce((sum, item) => { + const v = this.variant(item) + return sum + (v ? parseFloat(v.rashWeightGrams) * item.qty : 0) + }, 0) + .toFixed(2) + }, + clear() { + this.items = [] + persist(this.items) + }, + }, +}) diff --git a/apps/user-app/src/stores/configStore.ts b/apps/user-app/src/stores/configStore.ts new file mode 100644 index 0000000..7e5346d --- /dev/null +++ b/apps/user-app/src/stores/configStore.ts @@ -0,0 +1,93 @@ +// Scoped port of legacy henza_pwa/src/store/store.ts. +// +// Legacy fetched one curated payload (POST /front + GET /config) that carried +// the home banner slider, the category grid, promotions, the ignore-list and +// the gold price ticker. The new API exposes none of that as a single config +// endpoint, so this store restores the two pieces that drive visible home-page +// UI and are actually derivable today: +// +// - `cats` — REAL data: distinct categories derived from GET /catalog/products +// (the response already includes each product's category). +// Legacy's per-category `icon.image` was a remote URL from the old +// Laravel host and has no equivalent, so a local brand mark stands +// in until category artwork exists. +// - `config.main_slider` — legacy's banner slides were remote URLs too. These +// are placeholder slides built from legacy's own local artwork, +// using legacy's exact slide shape ({text, type, src, link}) so +// HomeSlider's popup/link/discount click handling works unchanged. +// +// Not ported (no backend): promotionList, ignoreList, priceList, isLoaded. +import { defineStore } from 'pinia' +import { api } from '../api/client' +import shippingArt from '../assets/img/shipping-and-delivery.png' +import competitionArt from '../assets/img/sports-and-competition.png' +import brandArt from '../assets/img/henza.svg' + +export interface Slide { + text: string | undefined + type: string | undefined + src: string | undefined + link: string | undefined +} + +export interface HomeCategory { + id: string + title: string + icon?: { image: string } +} + +const PLACEHOLDER_SLIDES: Slide[] = [ + { + type: 'popup', + src: shippingArt, + text: 'ارسال سریع و مطمئن سفارش‌های شما به سراسر کشور.', + link: undefined, + }, + { + type: 'popup', + src: competitionArt, + text: 'جدیدترین محصولات هنزاگلد را در بخش محصولات جدید ببینید.', + link: undefined, + }, + { + type: 'popup', + src: brandArt, + text: 'هنزاگلد، همراه شما در خرید و فروش طلا.', + link: undefined, + }, +] + +export const useConfigStore = defineStore('config', { + state: () => ({ + config: { main_slider: PLACEHOLDER_SLIDES as Slide[] }, + cats: [] as HomeCategory[], + loaded: false, + }), + actions: { + setCats(cats: HomeCategory[]) { + this.cats = cats + }, + // Legacy's store.init() fanned out to /config, /promotion, /ignore, /price. + // Here the only live source is the product catalogue, from which the real + // category list is derived. + async init() { + try { + const products = await api.listProducts() + const seen = new Map() + for (const p of products) { + if (p.category && !seen.has(p.category.id)) { + seen.set(p.category.id, { + id: p.category.id, + title: p.category.title, + icon: { image: brandArt }, + }) + } + } + this.cats = [...seen.values()] + this.loaded = true + } catch (e) { + console.log(e) + } + }, + }, +}) diff --git a/apps/user-app/src/stores/favoritesStore.ts b/apps/user-app/src/stores/favoritesStore.ts new file mode 100644 index 0000000..7e92db8 --- /dev/null +++ b/apps/user-app/src/stores/favoritesStore.ts @@ -0,0 +1,22 @@ +// Adapted from legacy henza_pwa/src/Mixins/favorite.ts + the `favorits` slice of +// myDataStore. Legacy persisted favorites server-side via POST /add-to-fav; the +// new API has no favorites endpoint yet, so this is local-only (Pinia state, +// not persisted) until that backend support exists. Flagged — not a silent +// behavior match, a deliberate stand-in. +import { defineStore } from 'pinia' + +export const useFavoritesStore = defineStore('favorites', { + state: () => ({ + ids: [] as string[], + }), + actions: { + toggle(id: string) { + const index = this.ids.indexOf(id) + if (index === -1) this.ids.push(id) + else this.ids.splice(index, 1) + }, + isFavorite(id: string) { + return this.ids.includes(id) + }, + }, +}) diff --git a/apps/user-app/src/stores/filtersStore.ts b/apps/user-app/src/stores/filtersStore.ts new file mode 100644 index 0000000..83f5991 --- /dev/null +++ b/apps/user-app/src/stores/filtersStore.ts @@ -0,0 +1,40 @@ +// Ported from legacy henza_pwa/src/store/filtersStore.ts — same field shape. +// `cats`, `brands`, `limit_gram`, and `text` are wired to real filtering in +// SearchView/Filters.vue. `colors` and `limit_percent` stay unused — see +// Filters.vue for why (no color data, wage-range filter was banker-only). +// `ciny_id`/`has_discount` are also unused (collections use a dedicated +// route param instead; no promotions data exists). +import { defineStore } from 'pinia' + +export interface FiltersQuery { + colors: string[] + brands: string[] + ciny_id: string[] + limit_percent: number[] + limit_gram: number[] + has_discount: boolean + text?: string + cats?: string[] + page: number +} + +const defaultQuery: FiltersQuery = { + colors: [], + brands: [], + ciny_id: [], + limit_percent: [], + limit_gram: [], + has_discount: false, + text: undefined, + cats: undefined, + page: 0, +} + +export const useFiltersStore = defineStore('filters', { + state: () => ({ query: { ...defaultQuery } as FiltersQuery }), + actions: { + reset() { + this.query = { ...defaultQuery } + }, + }, +}) diff --git a/apps/user-app/src/stores/loadingStore.ts b/apps/user-app/src/stores/loadingStore.ts new file mode 100644 index 0000000..ed63768 --- /dev/null +++ b/apps/user-app/src/stores/loadingStore.ts @@ -0,0 +1,14 @@ +// Ported from legacy henza_pwa/src/store/loadingStore.ts — a single global +// loading flag, converted from the hand-rolled ref-class to Pinia. +import { defineStore } from 'pinia' + +export const useLoadingStore = defineStore('loading', { + state: () => ({ + loading: false, + }), + actions: { + setLoading(loading: boolean) { + this.loading = loading + }, + }, +}) diff --git a/apps/user-app/src/stores/navStore.ts b/apps/user-app/src/stores/navStore.ts new file mode 100644 index 0000000..157d2f5 --- /dev/null +++ b/apps/user-app/src/stores/navStore.ts @@ -0,0 +1,46 @@ +// Ported from legacy henza_pwa/src/store/navStore.ts (a hand-rolled reactive +// class) to a real Pinia store, preserving the exact same state shape and +// setter behavior — including the pre-existing path/route-name mismatch on +// the "home" entry (path: '/' vs. the app's actual /home route), which is +// unused dead data since navigation uses `name`, not `path`. Ported as-is. +import { defineStore } from 'pinia' + +interface NavItem { + name: string + path: string + icon: string + index: number +} + +interface NavState { + navBarRoutes: NavItem[] + activeLink: number + showNav: boolean + // legacy passes `undefined` to clear the transition (router/index.ts:270) + pageTransition: string | undefined +} + +export const useNavStore = defineStore('nav', { + state: (): NavState => ({ + navBarRoutes: [ + { name: 'userPage::hasSubRoute', path: '/user', icon: 'vuesax-linear:user', index: 1 }, + { name: 'cart', path: '/cart', icon: 'vuesax-linear:bag-happy', index: 2 }, + { name: 'search', path: '/search', icon: 'vuesax-linear:search-normal-1', index: 3 }, + { name: 'home', path: '/', icon: 'vuesax-linear:home-2', index: 4 }, + ], + activeLink: 4, + showNav: true, + pageTransition: 'right', + }), + actions: { + setNavVisibility(data: boolean) { + this.showNav = data + }, + setActiveLink(activeLink: number) { + this.activeLink = activeLink + }, + setPgaTransition(transition: string | undefined) { + this.pageTransition = transition + }, + }, +}) diff --git a/apps/user-app/src/stores/session.ts b/apps/user-app/src/stores/session.ts new file mode 100644 index 0000000..22fe070 --- /dev/null +++ b/apps/user-app/src/stores/session.ts @@ -0,0 +1,35 @@ +import { defineStore } from 'pinia'; + +/** + * Holds the logged-in customer's identity. Now backed by a real (if stubbed — + * see LoginView.vue) login flow: `login()` sets the same `appToken` + * localStorage key legacy's `isUserLoggedIn()` reads, so downstream login + * gates keep working unchanged. + */ +export const useSessionStore = defineStore('session', { + state: () => ({ + customerId: localStorage.getItem('gold-bank:customerId') as string | null, + mobile: localStorage.getItem('gold-bank:mobile') as string | null, + }), + actions: { + setCustomerId(id: string) { + this.customerId = id; + localStorage.setItem('gold-bank:customerId', id); + }, + login(customerId: string, mobile: string) { + this.customerId = customerId; + this.mobile = mobile; + localStorage.setItem('gold-bank:customerId', customerId); + localStorage.setItem('gold-bank:mobile', mobile); + // matches legacy's isUserLoggedIn() check (Mixins/checkAccess.ts) + localStorage.setItem('appToken', customerId); + }, + logout() { + this.customerId = null; + this.mobile = null; + localStorage.removeItem('gold-bank:customerId'); + localStorage.removeItem('gold-bank:mobile'); + localStorage.removeItem('appToken'); + }, + }, +}); diff --git a/apps/user-app/src/stores/transitionStore.ts b/apps/user-app/src/stores/transitionStore.ts new file mode 100644 index 0000000..fa28dc4 --- /dev/null +++ b/apps/user-app/src/stores/transitionStore.ts @@ -0,0 +1,27 @@ +// Ported from legacy henza_pwa/src/store/transitionStore.ts. +import { defineStore } from 'pinia' + +interface TransitionState { + transitionFlag: boolean + transitionEnded: boolean + headerHeight: number +} + +export const useTransitionStore = defineStore('transition', { + state: (): TransitionState => ({ + transitionFlag: true, + transitionEnded: true, + headerHeight: 0, + }), + actions: { + setTransitionFlag(transitionFlag: boolean) { + this.transitionFlag = transitionFlag + }, + setTransitionEnded(transitionEnded: boolean) { + this.transitionEnded = transitionEnded + }, + setHeaderHeight(height: number) { + this.headerHeight = height + }, + }, +}) diff --git a/apps/user-app/src/stores/windowDataStore.ts b/apps/user-app/src/stores/windowDataStore.ts new file mode 100644 index 0000000..c7502b8 --- /dev/null +++ b/apps/user-app/src/stores/windowDataStore.ts @@ -0,0 +1,22 @@ +// Ported from legacy henza_pwa/src/store/windowData.ts. +import { defineStore } from 'pinia' + +interface WindowDataState { + width: number + pullingDown: boolean +} + +export const useWindowDataStore = defineStore('windowData', { + state: (): WindowDataState => ({ + width: 0, + pullingDown: false, + }), + actions: { + setWidth(width: number) { + this.width = width + }, + setPullingDown(pulling: boolean) { + this.pullingDown = pulling + }, + }, +}) diff --git a/apps/user-app/src/sw.ts b/apps/user-app/src/sw.ts new file mode 100644 index 0000000..589176d --- /dev/null +++ b/apps/user-app/src/sw.ts @@ -0,0 +1,14 @@ +/// +// Ported verbatim from legacy henza_pwa/src/sw.ts. +// injectManifest strategy: vite-plugin-pwa replaces self.__WB_MANIFEST with the +// generated precache list at build time. +import { precacheAndRoute } from 'workbox-precaching' + +declare let self: ServiceWorkerGlobalScope + +self.addEventListener('message', (event) => { + if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting() +}) + +// self.__WB_MANIFEST is default injection point +precacheAndRoute(self.__WB_MANIFEST) diff --git a/apps/user-app/src/types/moment-jalaali-shim.d.ts b/apps/user-app/src/types/moment-jalaali-shim.d.ts new file mode 100644 index 0000000..4be4301 --- /dev/null +++ b/apps/user-app/src/types/moment-jalaali-shim.d.ts @@ -0,0 +1,5 @@ +// moment-jalaali ships no type declarations of its own. +declare module 'moment-jalaali' { + import moment from 'moment'; + export default moment; +} diff --git a/apps/user-app/src/types/swiper-shim.d.ts b/apps/user-app/src/types/swiper-shim.d.ts new file mode 100644 index 0000000..081c72b --- /dev/null +++ b/apps/user-app/src/types/swiper-shim.d.ts @@ -0,0 +1,16 @@ +// swiper@7.4.1's package.json "exports" map doesn't expose its own .d.ts +// files correctly under TS's "bundler" moduleResolution (a known issue with +// this swiper version) — declared loosely here so the port compiles without +// upgrading/replacing the same swiper version legacy uses. +declare module 'swiper' { + const SwiperCore: any; + export const Pagination: any; + export const Navigation: any; + export const Autoplay: any; + export const Zoom: any; + export default SwiperCore; +} +declare module 'swiper/vue' { + export const Swiper: any; + export const SwiperSlide: any; +} diff --git a/apps/user-app/src/utils/checkAccess.ts b/apps/user-app/src/utils/checkAccess.ts new file mode 100644 index 0000000..cd19cc6 --- /dev/null +++ b/apps/user-app/src/utils/checkAccess.ts @@ -0,0 +1,6 @@ +// Ported verbatim from legacy henza_pwa/src/Mixins/checkAccess.ts (isUserLoggedIn +// only — canNavigate/getHomeRouteForLoggedInUser were dead/unwired in legacy too). +// No login flow exists yet in this port, so this always returns falsy for now. +export function isUserLoggedIn() { + return localStorage.getItem('appToken') +} diff --git a/apps/user-app/src/utils/filters.ts b/apps/user-app/src/utils/filters.ts new file mode 100644 index 0000000..18901cc --- /dev/null +++ b/apps/user-app/src/utils/filters.ts @@ -0,0 +1,36 @@ +// Ported from legacy henza_pwa/src/Mixins/filters/index.ts. Note: jalali dates +// render with Western digits (moment-jalaali's default), not Persian numerals +// — preserved exactly, do not swap for Intl's 'fa-IR' locale (which would +// silently introduce Persian digits legacy never used anywhere). +import moment from 'moment-jalaali' + +export function price(val: number | string | undefined | null): string { + if (val) { + return val.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + } + return val as any +} + +export function priceUnit(val: number | string | undefined | null): string { + if (['string', 'number'].includes(typeof val)) { + return `${val!.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')} تومان ` + } + return val as any +} + +export function priceUnitRialLess(val: number | string | undefined | null): string { + if (['string', 'number'].includes(typeof val)) { + return `${val!.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',')} ` + } + return val as any +} + +export function jdate(val: string | undefined | null): string { + if (val) { + // legacy parsed a fixed 'YYYY-MM-DD HH:mm:ss' Laravel timestamp format; + // our API returns ISO 8601 instead, which moment parses natively without + // needing an explicit format string. + return moment(val).format('jYYYY/jMM/jDD') + } + return val as any +} diff --git a/apps/user-app/src/utils/orderStatus.ts b/apps/user-app/src/utils/orderStatus.ts new file mode 100644 index 0000000..455966a --- /dev/null +++ b/apps/user-app/src/utils/orderStatus.ts @@ -0,0 +1,54 @@ +// Persian labels ported from the legacy Laravel Order::$STS enum (henza_server +// app/Models/Order.php) — the authoritative source, not henza_pwa's own +// Orders.vue filter-tab labels, which used a slightly different, inconsistent +// set (its key=0 was labeled "لغو شده"/cancelled, while the actual backend +// model's sts=0 means "معلق"/suspended — legacy itself was inconsistent here). +// Our OrderStatus enum cleanly separates SUSPENDED and CANCELLED, so mapped +// directly, one label per real status. +export const ORDER_STATUS_LABELS: Record = { + SUSPENDED: 'معلق', + PLACED: 'ثبت سفارش', + ADMIN_APPROVED: 'تایید مدیر سیستم', + SOURCING: 'درحال تامین', + PREPARING: 'درحال آماده سازی', + DELIVERED_TO_AGENT: 'تحویل مسئول فروش', + DELIVERED_TO_CUSTOMER: 'تحویل مشتری', + CANCELLED: 'لغو شده', +}; + +// Ported from legacy order.vue/Orders.vue's setColor() — legacy only ever +// colored sts 1,3,4,5,6 (never sts=0 or sts=2), an existing gap preserved here +// for the directly-analogous statuses (ADMIN_APPROVED, SUSPENDED, CANCELLED +// stay uncolored, same as legacy's untouched sts=0/2). +export const ORDER_STATUS_COLORS: Record = { + PLACED: { font: '#3B82F6', background: '#EFF6FF' }, + SOURCING: { font: '#EAB308', background: '#FEFCE8' }, + PREPARING: { font: '#F97316', background: '#FFF7ED' }, + DELIVERED_TO_AGENT: { font: '#06B6D4', background: '#ECFEFF' }, + DELIVERED_TO_CUSTOMER: { font: '#22C55E', background: '#F0FDF4' }, +}; + +// Ported from legacy Orders.vue's 4-step tracker (`steps`/`setCurrentStep`). +// Legacy's ambiguous sts=0 (suspended vs cancelled) is resolved here since our +// enum keeps them distinct; SUSPENDED is grouped with the first step (not yet +// progressing), matching the spirit of legacy's grouping of early statuses. +export const ORDER_STEPS = ['ثبت سفارش', 'آماده سازی', 'تحویل مشتری', 'لغو شده']; + +export function orderStepIndex(status: string): number { + switch (status) { + case 'PLACED': + case 'ADMIN_APPROVED': + case 'SUSPENDED': + return 0; + case 'SOURCING': + case 'PREPARING': + return 1; + case 'DELIVERED_TO_AGENT': + case 'DELIVERED_TO_CUSTOMER': + return 2; + case 'CANCELLED': + return 3; + default: + return 0; + } +} diff --git a/apps/user-app/src/utils/validator.ts b/apps/user-app/src/utils/validator.ts new file mode 100644 index 0000000..4261dc9 --- /dev/null +++ b/apps/user-app/src/utils/validator.ts @@ -0,0 +1,30 @@ +// Ported from legacy henza_pwa/src/Mixins/validator/index.ts — same rules, +// same messages, verbatim. +export const message = (field: string) => ` مقدار ${field} نا معتبر است ` + +export const mobile = (field: string) => ({ + pattern: /^(\+98|0098|98|0)?9\d{9}$/g, + message: message(field), +}) +export const phone = (field: string) => ({ + pattern: /^((\+98|0098|98|0)\d1)?\d{8}$/g, + message: message(field), +}) +export const fa = (field: string) => ({ + pattern: /^([؀-ۿﮊپچگ‌ ])+$/, + message: ` مقدار ${field} باید فارسی باشد `, +}) +export const mail = (field: string) => ({ type: 'email', message: message(field) }) +export const url = (field: string) => ({ type: 'url', message: message(field) }) +export const username = (field: string) => ({ + pattern: /^(?!.*\.\.)(?!.*\.$)[^\W][\w.]{0,29}$/gim, + message: ` مقدار ${field} باید شامل حروف و اعداد انگلیسی و _ باشد `, +}) +export const required = (field: string) => ({ required: true, message: ` لطفا ${field} را وارد کنید ` }) +export const sheba = (field: string) => ({ pattern: /^IR\d{24}$/gim, message: message(field) }) +export const card = (field: string) => ({ pattern: /^\d{16}$/gim, message: message(field) }) +export const zip = (field: string) => ({ pattern: /^\d{10}$/gim, message: message(field) }) +export const num = (field: string) => ({ pattern: /^\d*$/gim, message: message(field) }) +export const len = (field: string, len: number) => ({ len, message: ` مقدار ${field} باید ${len} کارکتر باشد ` }) +export const max = (field: string, max: number) => ({ max, message: ` مقدار ${field} باید کمتر از ${max} کارکتر باشد ` }) +export const min = (field: string, min: number) => ({ min, message: ` مقدار ${field} باید بیشتر از ${min} کارکتر باشد ` }) diff --git a/apps/user-app/src/views/CartView.vue b/apps/user-app/src/views/CartView.vue new file mode 100644 index 0000000..b2432bf --- /dev/null +++ b/apps/user-app/src/views/CartView.vue @@ -0,0 +1,229 @@ + +