erp-city-vue-components 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,76 @@
1
+ # erp-city-vue-components
2
+
3
+ Shared Vue 3 components + reactive state used for ERP storefront flows (auth, cart, checkout, favorites, orders).
4
+ Designed to integrate with `erp-city` and `erp-city-client`:
5
+
6
+ - https://github.com/AntonioPrimera/erp-city
7
+ - https://github.com/AntonioPrimera/erp-city-client
8
+
9
+ ## Install
10
+
11
+ Local development (recommended in monorepo-style):
12
+
13
+ ```bash
14
+ npm install ../erp-city-vue-components
15
+ ```
16
+
17
+ Or add to your app `package.json`:
18
+
19
+ ```json
20
+ {
21
+ "dependencies": {
22
+ "erp-city-vue-components": "file:../erp-city-vue-components"
23
+ }
24
+ }
25
+ ```
26
+
27
+ Then run:
28
+
29
+ ```bash
30
+ npm install
31
+ ```
32
+
33
+ ## Requirements
34
+
35
+ - Vue 3
36
+ - Axios
37
+ - Ziggy JS (`ziggy-js`)
38
+ - A global `route()` helper (Ziggy) available at runtime
39
+ - A globally registered `v-input` component (used by forms in the sidebar)
40
+
41
+ ## Usage
42
+
43
+ ```js
44
+ import { createApp } from 'vue';
45
+ import {
46
+ Sidebar,
47
+ cartState,
48
+ productsState,
49
+ authState,
50
+ favoritesState,
51
+ sidebarState,
52
+ setErpCityUiConfig,
53
+ } from 'erp-city-vue-components';
54
+
55
+ setErpCityUiConfig({
56
+ showFavorites: false,
57
+ });
58
+
59
+ productsState.loadProducts();
60
+ cartState.loadCart();
61
+ authState.bootstrap();
62
+
63
+ const app = createApp({});
64
+ app.component('v-sidebar', Sidebar);
65
+ app.mount('#app');
66
+
67
+ // Example usage
68
+ sidebarState.open('cart');
69
+ ```
70
+
71
+ ## Exports
72
+
73
+ - Components: `Sidebar`, `CartButton`, `AuthButton`, `AuthSidebar`, `OrdersSidebar`, `FavoritesSidebar`, `CartDetails`, `AddressForm`, `OrderConfirmation`, `PaymentOrderConfirmation`, `ProductCard`, `FavoriteProductCard`, `QuantityInput`
74
+ - State: `authState`, `cartState`, `favoritesState`, `ordersState`, `productsState`, `sidebarState`
75
+ - Composables: `useHandlesFormErrors`
76
+ - Config: `setErpCityUiConfig`, `erpCityUiConfig`
package/package.json ADDED
@@ -0,0 +1,20 @@
1
+ {
2
+ "name": "erp-city-vue-components",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "./src/index.js",
6
+ "exports": {
7
+ ".": "./src/index.js",
8
+ "./components/*": "./src/components/*.vue",
9
+ "./state/*": "./src/state/*.js",
10
+ "./composables/*": "./src/composables/*.js"
11
+ },
12
+ "files": [
13
+ "src"
14
+ ],
15
+ "peerDependencies": {
16
+ "axios": "^1.0.0",
17
+ "vue": "^3.0.0",
18
+ "ziggy-js": "^2.0.0"
19
+ }
20
+ }
@@ -0,0 +1,249 @@
1
+ <script setup>
2
+ import axios from "axios";
3
+ import { sidebarState } from "../state/sidebarState.js";
4
+ import { cartState } from "../state/cartState.js";
5
+ import { authState } from "../state/authState.js";
6
+ import { useHandlesFormErrors } from "../composables/handlesFormErrors.js";
7
+ import { onMounted, computed, reactive, ref, watch } from "vue";
8
+ import { erpCityUiConfig } from "../config.js";
9
+
10
+ const {error, setErrorBag} = useHandlesFormErrors();
11
+
12
+ const addressData = reactive({
13
+ name: null,
14
+ address: null,
15
+ phone: null,
16
+ email: null,
17
+ company: null
18
+ })
19
+
20
+ let loading = ref(false);
21
+ const cardPaymentsEnabled = ref(true);
22
+ const paymentType = ref(cardPaymentsEnabled.value ? 'card' : 'on_delivery');
23
+ const paymentMethodsLoaded = ref(false);
24
+ const hasPrefilledFromProfile = ref(false);
25
+ const isPrefillingProfile = ref(false);
26
+ const profileFieldMap = {
27
+ name: "name",
28
+ email: "email",
29
+ phone: "phone",
30
+ address: "address",
31
+ company: "company",
32
+ };
33
+ const showCompany = computed(() => erpCityUiConfig.showCompany);
34
+
35
+ watch(addressData, (newData) => {
36
+ localStorage.setItem('addressData', JSON.stringify(newData));
37
+ }, { deep: true });
38
+
39
+ const isMissingAddressField = (value) => value === null || value === undefined || value === '';
40
+
41
+ async function prefillAddressFromProfile() {
42
+ if (!authState.isAuthenticated || hasPrefilledFromProfile.value || isPrefillingProfile.value) {
43
+ return;
44
+ }
45
+
46
+ isPrefillingProfile.value = true;
47
+
48
+ try {
49
+ const { data } = await axios.get(route('erp.auth.me'));
50
+ const profile = data?.data;
51
+
52
+ if (!profile) {
53
+ return;
54
+ }
55
+
56
+ Object.entries(profileFieldMap).forEach(([profileKey, addressKey]) => {
57
+ const profileValue = profile[profileKey];
58
+
59
+ if (profileValue && isMissingAddressField(addressData[addressKey])) {
60
+ addressData[addressKey] = profileValue;
61
+ }
62
+ });
63
+
64
+ hasPrefilledFromProfile.value = true;
65
+ } catch (error) {
66
+ console.warn('Failed to prefill address data from ERP profile', error);
67
+ } finally {
68
+ isPrefillingProfile.value = false;
69
+ }
70
+ }
71
+
72
+ function confirmOrder() {
73
+ loading.value = true;
74
+
75
+ axios.post(route('erp.orders.store'), {
76
+ ...addressData,
77
+ items: cartState.items,
78
+ payment_type: cartState.price ? paymentType.value : null,
79
+ coupon_code: cartState.coupon?.code ?? null,
80
+ })
81
+ .then((response) => {
82
+ const checkoutUrl = response?.data?.checkout_url;
83
+ if (checkoutUrl) {
84
+ window.location.href = checkoutUrl;
85
+ return;
86
+ }
87
+
88
+ if (cartState.price) {
89
+ sidebarState.open('payment-confirmation');
90
+ } else {
91
+ sidebarState.open('confirmation');
92
+ }
93
+ cartState.clearCart();
94
+ localStorage.removeItem('addressData');
95
+ })
96
+ .catch(error => {
97
+ setErrorBag(error);
98
+ console.error("Error saving order:", error);
99
+ })
100
+ .finally(() => {
101
+ loading.value = false;
102
+ });
103
+ }
104
+
105
+ onMounted(() => {
106
+ const savedAddress = localStorage.getItem('addressData');
107
+
108
+ if (savedAddress) {
109
+ Object.assign(addressData, JSON.parse(savedAddress));
110
+ }
111
+
112
+ if (authState.isAuthenticated) {
113
+ prefillAddressFromProfile();
114
+ }
115
+
116
+ sidebarState.setHeader('Adresa', true);
117
+ loadPaymentMethods();
118
+ });
119
+
120
+ watch(
121
+ () => authState.isAuthenticated,
122
+ (isAuthenticated) => {
123
+ if (isAuthenticated) {
124
+ prefillAddressFromProfile();
125
+ } else {
126
+ hasPrefilledFromProfile.value = false;
127
+ }
128
+ }
129
+ );
130
+
131
+ async function loadPaymentMethods() {
132
+ try {
133
+ const { data } = await axios.get(route('erp.paymentMethods'));
134
+ const cardEnabled = data?.data?.card_enabled;
135
+ if (typeof cardEnabled === 'boolean') {
136
+ cardPaymentsEnabled.value = cardEnabled;
137
+ }
138
+ } catch (error) {
139
+ console.warn('Failed to load ERP payment methods', error);
140
+ } finally {
141
+ if (!cardPaymentsEnabled.value) {
142
+ paymentType.value = 'on_delivery';
143
+ }
144
+ paymentMethodsLoaded.value = true;
145
+ }
146
+ }
147
+ </script>
148
+
149
+ <template>
150
+ <div class="grid grid-cols-1 gap-4 lg:gap-4">
151
+ <v-input class="col-span-full"
152
+ id="name"
153
+ label="Nume și prenume"
154
+ placeholder="Nume și prenume"
155
+ :error="error('name')"
156
+ autofocus
157
+ v-model="addressData.name"
158
+ ></v-input>
159
+
160
+ <v-input v-if="showCompany"
161
+ id="company"
162
+ label="Societatea"
163
+ placeholder="Societatea"
164
+ :error="error('company')"
165
+ v-model="addressData.company"
166
+ ></v-input>
167
+
168
+ <v-input id="address"
169
+ label="Adresa"
170
+ placeholder="Adresa"
171
+ :error="error('address')"
172
+ v-model="addressData.address"
173
+ ></v-input>
174
+
175
+ <v-input id="phone"
176
+ label="Număr de telefon"
177
+ placeholder="Număr de telefon"
178
+ inputmode="tel"
179
+ :error="error('phone')"
180
+ v-model="addressData.phone"
181
+ ></v-input>
182
+
183
+ <v-input id="email"
184
+ label="Email"
185
+ placeholder="Email"
186
+ inputmode="email"
187
+ :error="error('email')"
188
+ v-model="addressData.email"
189
+ ></v-input>
190
+ </div>
191
+
192
+ <div v-if="cartState.price && paymentMethodsLoaded" class="mt-8">
193
+ <div class="font-medium text-lg lg:text-xl mb-4">Selectează metoda de plată</div>
194
+
195
+ <div class="space-y-4 lg:space-y-6">
196
+ <div
197
+ v-if="cardPaymentsEnabled"
198
+ class="border border-primary p-7 rounded-xl flex items-center cursor-pointer"
199
+ :class="{ 'bg-primary/5': paymentType === 'card' }"
200
+ @click="paymentType = 'card'"
201
+ >
202
+ <div class="mr-5.5">
203
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
204
+ <circle cx="12" cy="12" r="11.25" fill="white" stroke="#2F5233" stroke-width="1.5"/>
205
+ <circle
206
+ cx="12.0007"
207
+ cy="12"
208
+ r="6.66667"
209
+ :fill="paymentType === 'card' ? '#2F5233' : 'white'"
210
+ :stroke="paymentType === 'card' ? '#2F5233' : 'white'"
211
+ />
212
+ </svg>
213
+ </div>
214
+ <div class="font-medium text-lg lg:text-xl">Card</div>
215
+ </div>
216
+
217
+ <div
218
+ class="border border-primary p-7 rounded-xl flex items-center cursor-pointer"
219
+ :class="{ 'bg-primary/5': paymentType === 'on_delivery' }"
220
+ @click="paymentType = 'on_delivery'"
221
+ >
222
+ <div class="mr-5.5">
223
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
224
+ <circle cx="12" cy="12" r="11.25" fill="white" stroke="#2F5233" stroke-width="1.5"/>
225
+ <circle
226
+ cx="12.0007"
227
+ cy="12"
228
+ r="6.66667"
229
+ :fill="paymentType === 'on_delivery' ? '#2F5233' : 'white'"
230
+ :stroke="paymentType === 'on_delivery' ? '#2F5233' : 'white'"
231
+ />
232
+ </svg>
233
+ </div>
234
+ <div class="font-medium text-lg lg:text-xl">Ramburs</div>
235
+ </div>
236
+ </div>
237
+ </div>
238
+
239
+ <button type="button"
240
+ class="btn btn-primary btn-full mt-6"
241
+ @click="confirmOrder"
242
+ >
243
+ {{
244
+ cartState.price
245
+ ? (cardPaymentsEnabled && paymentType === 'card' ? 'Continuă spre plată' : 'Finalizează')
246
+ : 'Trimite solicitare'
247
+ }}
248
+ </button>
249
+ </template>
@@ -0,0 +1,35 @@
1
+ <script setup>
2
+ import { computed } from 'vue';
3
+ import {authState} from "../state/authState.js";
4
+ import {sidebarState} from "../state/sidebarState.js";
5
+
6
+ const iconSrc = '/img/icons/auth.svg';
7
+ const initials = computed(() => {
8
+ const name = authState.user?.name || [authState.user?.first_name, authState.user?.last_name].filter(Boolean).join(' ');
9
+ if (! name) {
10
+ return '';
11
+ }
12
+
13
+ return name
14
+ .split(' ')
15
+ .filter(Boolean)
16
+ .slice(0, 2)
17
+ .map(part => part[0]?.toUpperCase())
18
+ .join('');
19
+ });
20
+
21
+ function openAuthSidebar() {
22
+ sidebarState.setHeader(authState.isAuthenticated ? 'Contul meu' : 'Intră în cont', false);
23
+ sidebarState.open('auth');
24
+ }
25
+ </script>
26
+
27
+ <template>
28
+ <button type="button"
29
+ class="cursor-pointer flex items-center justify-center ml-4"
30
+ :class="{'bg-primary text-white rounded-full h-8 w-8' : authState.isAuthenticated}"
31
+ @click="openAuthSidebar">
32
+ <span v-if="authState.isAuthenticated" class="text-sm font-semibold">{{ initials }}</span>
33
+ <img v-else :src="iconSrc" alt="Profil" class="h-5.5 w-5.5" />
34
+ </button>
35
+ </template>
@@ -0,0 +1,218 @@
1
+ <script setup>
2
+ import { ref, computed, watch } from 'vue';
3
+ import { authState } from '../state/authState.js';
4
+ import { favoritesState } from '../state/favoritesState.js';
5
+ import { ordersState } from '../state/ordersState.js';
6
+ import { sidebarState } from '../state/sidebarState.js';
7
+ import { erpCityUiConfig } from '../config.js';
8
+
9
+ const mode = ref('login');
10
+ const form = ref({
11
+ email: '',
12
+ password: '',
13
+ password_confirmation: '',
14
+ name: '',
15
+ });
16
+ const formErrors = ref({});
17
+ const generalError = ref('');
18
+ const isLoadingFavorites = computed(() => favoritesState.loading);
19
+ const isLoadingOrders = computed(() => ordersState.loading);
20
+ const showFavorites = computed(() => erpCityUiConfig.showFavorites);
21
+ const showOrders = computed(() => erpCityUiConfig.showOrders);
22
+
23
+ const sidebarTitle = computed(() => {
24
+ if (authState.isAuthenticated) {
25
+ return 'Contul meu';
26
+ }
27
+
28
+ return mode.value === 'login' ? 'Intră în cont' : 'Creează cont';
29
+ });
30
+
31
+ watch(sidebarTitle, (title) => {
32
+ sidebarState.setHeader(title, false);
33
+ });
34
+
35
+ sidebarState.setHeader(sidebarTitle.value, false);
36
+
37
+ function resetForm() {
38
+ form.value = {
39
+ email: '',
40
+ password: '',
41
+ password_confirmation: '',
42
+ name: '',
43
+ };
44
+ formErrors.value = {};
45
+ generalError.value = '';
46
+ }
47
+
48
+ async function onSubmit() {
49
+ formErrors.value = {};
50
+ generalError.value = '';
51
+
52
+ try {
53
+ if (mode.value === 'login') {
54
+ await authState.login({
55
+ email: form.value.email,
56
+ password: form.value.password,
57
+ });
58
+ } else {
59
+ await authState.register({
60
+ name: form.value.name,
61
+ email: form.value.email,
62
+ password: form.value.password,
63
+ password_confirmation: form.value.password_confirmation,
64
+ });
65
+ }
66
+
67
+ const nextView = sidebarState.nextView;
68
+ sidebarState.nextView = null;
69
+ resetForm();
70
+
71
+ if (nextView) {
72
+ sidebarState.open(nextView);
73
+ }
74
+ } catch (error) {
75
+ const responseData = error.response?.data;
76
+ const responseErrors = responseData?.errors;
77
+ if (responseErrors) {
78
+ formErrors.value = Object.fromEntries(
79
+ Object.entries(responseErrors).map(([key, messages]) => [key, messages.join(' ')])
80
+ );
81
+ } else if (responseData?.message) {
82
+ generalError.value = responseData.message;
83
+ }
84
+ }
85
+ }
86
+
87
+ function switchMode(newMode) {
88
+ mode.value = newMode;
89
+ resetForm();
90
+ }
91
+
92
+ function viewFavorites() {
93
+ if (! showFavorites.value) {
94
+ return;
95
+ }
96
+ favoritesState.fetchFavorites();
97
+ sidebarState.setHeader('Produse favorite', true);
98
+ sidebarState.open('favorites');
99
+ }
100
+
101
+ function viewOrders() {
102
+ if (! showOrders.value) {
103
+ return;
104
+ }
105
+ ordersState.fetchOrders();
106
+ sidebarState.setHeader('Comenzile mele', true);
107
+ sidebarState.open('orders');
108
+ }
109
+
110
+ async function onLogout() {
111
+ await authState.logout();
112
+ sidebarState.close();
113
+ }
114
+ </script>
115
+
116
+ <template>
117
+ <div class="flex flex-col">
118
+ <template v-if="!authState.isAuthenticated">
119
+ <form @submit.prevent="onSubmit" class="flex flex-col flex-1">
120
+ <div class="space-y-4">
121
+ <div v-if="mode === 'register'">
122
+ <v-input
123
+ id="name"
124
+ label="Nume complet"
125
+ :error="formErrors.name"
126
+ placeholder="Introduceți numele"
127
+ v-model="form.name"
128
+ />
129
+ </div>
130
+
131
+ <v-input
132
+ id="email"
133
+ label="Email"
134
+ :error="formErrors.email"
135
+ placeholder="email@example.com"
136
+ type="email"
137
+ v-model="form.email"
138
+ />
139
+
140
+ <v-input
141
+ id="password"
142
+ label="Parolă"
143
+ :error="formErrors.password"
144
+ placeholder="Parola"
145
+ type="password"
146
+ v-model="form.password"
147
+ />
148
+
149
+ <div v-if="mode === 'register'">
150
+ <v-input
151
+ id="password_confirmation"
152
+ label="Confirmă parola"
153
+ :error="formErrors.password_confirmation"
154
+ placeholder="Reintroduceți parola"
155
+ type="password"
156
+ v-model="form.password_confirmation"
157
+ />
158
+ </div>
159
+
160
+ <div class="mt-6 mb-2 text-right text-sm text-gray-600">
161
+ <div v-if="mode === 'register'">
162
+ Ai deja un cont? <span class="underline cursor-pointer" @click="switchMode('login')">Intră în cont</span>
163
+ </div>
164
+ <div v-else>
165
+ Nu ai un cont? <span class="underline cursor-pointer" @click="switchMode('register')">Înregistrează-te</span>
166
+ </div>
167
+ </div>
168
+ </div>
169
+
170
+ <div v-if="generalError" class="text-red-600 text-sm mb-4">
171
+ {{ generalError }}
172
+ </div>
173
+
174
+ <button type="submit" class="btn btn-primary btn-full mt-auto" :disabled="authState.status === 'loading'">
175
+ {{ mode === 'login' ? 'Autentifică-te' : 'Înregistrează-te' }}
176
+ </button>
177
+ </form>
178
+ </template>
179
+
180
+ <template v-else>
181
+ <div class="h-full flex flex-col">
182
+ <div class="-mt-10 mb-4">
183
+ <p class="text-sm text-gray-500 mb-1">Ești autentificat ca</p>
184
+ <p class="text-lg font-semibold">{{ authState.user?.name || [authState.user?.first_name, authState.user?.last_name].filter(Boolean).join(' ') }}</p>
185
+ <p class="text-gray-700">{{ authState.user?.email }}</p>
186
+ </div>
187
+
188
+ <div v-if="showFavorites" class="flex items-center justify-between pb-8 pt-8 border-b border-b-neutral-200">
189
+ <h4 class="text-lg font-semibold">
190
+ Produse favorite
191
+ <span class="text-neutral-400 font-normal">
192
+ ({{ favoritesState.ids.length }})
193
+ </span>
194
+ </h4>
195
+ <button type="button" class="text-primary underline" @click="viewFavorites" :disabled="isLoadingFavorites">
196
+ Vezi
197
+ </button>
198
+ </div>
199
+
200
+ <div v-if="showOrders" class="flex items-center justify-between pb-8 pt-8 border-b border-b-neutral-200">
201
+ <h4 class="text-lg font-semibold">
202
+ Comenzile mele
203
+ <span class="text-neutral-400 font-normal">
204
+ ({{ ordersState.orders.length }})
205
+ </span>
206
+ </h4>
207
+ <button type="button" class="text-primary underline" @click="viewOrders" :disabled="isLoadingOrders">
208
+ Vezi
209
+ </button>
210
+ </div>
211
+ </div>
212
+
213
+ <button class="btn btn-primary btn-full mt-8" @click="onLogout" :disabled="authState.status === 'loading'">
214
+ Deconectează-te
215
+ </button>
216
+ </template>
217
+ </div>
218
+ </template>
@@ -0,0 +1,18 @@
1
+ <script setup>
2
+ import { cartState, sidebarState } from "erp-city-vue-components";
3
+
4
+ const cartIconSrc = '/img/icons/cart.svg';
5
+ </script>
6
+
7
+ <template>
8
+ <div class="block relative ml-auto cursor-pointer"
9
+ @click="sidebarState.open('cart')"
10
+ >
11
+ <div v-if="cartState.count"
12
+ class="absolute bottom-3 left-3 flex items-center justify-center rounded-full bg-[#C73838] w-5.5 h-5.5 text-white text-center"
13
+ >
14
+ {{ cartState.count}}
15
+ </div>
16
+ <img :src="cartIconSrc" alt="Cart">
17
+ </div>
18
+ </template>