Features: 1) Introduce useUserBaseData composable to fetch and manage user's wishlist, orders, and promocodes; 2) Add reusable useOrders and useOrderOverwrite composables with advanced filtering and pagination; 3) Implement order.vue component for detailed order displays with UI enhancements;

Fixes: 1) Replace deprecated context usage in `useAvatarUpload` mutation; 2) Resolve incorrect locale parsing in `useDate` utility and fix non-reactive cart state in `profile/cart.vue`; 3) Update stale imports and standardize type naming across composables;

Extra: 1) Refactor i18n strings including order status and search-related texts; 2) Replace temporary workarounds with `apollo-upload-client` configuration and add `apollo-upload-link.ts` plugin; 3) Cleanup redundant files, comments, and improve SCSS structure with new variables and placeholders.
This commit is contained in:
Alexandr SaVBaD Waltz 2025-07-11 18:39:13 +03:00
parent c60ac13e88
commit 52b32bd608
40 changed files with 984 additions and 148 deletions

View file

@ -37,6 +37,17 @@ button:focus-visible {
margin-inline: auto;
}
//::-webkit-scrollbar {
// width: 12px;
//}
//
//::-webkit-scrollbar-thumb {
// background-color: rgba($accent, 0.5);
// border-radius: $default_border_radius;
// -webkit-transition: all .2s ease;
// transition: all .2s ease;
//}
::-webkit-scrollbar-thumb {
background: $accent;
}

View file

@ -0,0 +1,246 @@
<template>
<el-collapse-item
class="order"
>
<template #title="{ isActive }">
<div :class="['order__top', { 'is-active': isActive }]">
<div>
<p>{{ t('profile.orders.id') }}: {{ order.humanReadableId }}</p>
<p v-if="order.buyTime">{{ useDate(order.buyTime, locale) }}</p>
<el-tooltip
:content="order.status"
placement="top"
>
<p class="status" :style="[{ backgroundColor: statusColor(order.status) }]">
{{ order.status }}
<icon name="material-symbols:info-outline-rounded" size="14" />
</p>
</el-tooltip>
</div>
<icon
name="material-symbols:keyboard-arrow-down"
size="22"
class="order__top-icon"
:class="[{ active: isActive }]"
/>
</div>
<div class="order__top-bottom" :class="{ active: !isActive }">
<div>
<div>
<div>
<nuxt-img
v-for="product in order.orderProducts.edges"
:key="product.node.uuid"
:src="product.node.product.images.edges[0].node.image"
:alt="product.node.product.name"
format="webp"
densities="x1"
/>
</div>
<p>{{ order.totalPrice }}{{ CURRENCY }}</p>
</div>
</div>
</div>
</template>
<div class="order__main">
<div
class="order__product"
v-for="product in order.orderProducts.edges"
:key="product.node.uuid"
>
<div class="order__product-left">
<nuxt-img
:src="product.node.product.images.edges[0].node.image"
:alt="product.node.product.name"
/>
<p>{{ product.node.product.name }}</p>
</div>
<div class="order__product-right">
<h6>{{ t('profile.orders.price') }}: {{ product.node.product.price * product.node.quantity }}{{ CURRENCY }}</h6>
<p>{{ product.node.quantity }} X {{ product.node.product.price }}{{ CURRENCY }}</p>
</div>
</div>
<div class="order__total">
<p>{{ t('profile.orders.total') }}: {{ order.totalPrice }}{{ CURRENCY }}</p>
</div>
</div>
</el-collapse-item>
</template>
<script setup lang="ts">
import {useDate} from "~/composables/date";
import {CURRENCY, orderStatuses} from "~/config/constants";
import type {IOrder} from "~/types";
const props = defineProps<{
order: IOrder;
}>();
const {t, locale} = useI18n();
const statusColor = (status: string) => {
switch (status) {
case orderStatuses.FAILED:
return '#FF0000';
case orderStatuses.PAYMENT:
return '#FFC107';
case orderStatuses.CREATED:
return '#007BFF';
case orderStatuses.DELIVERING:
return '#00C853';
case orderStatuses.FINISHED:
return '#00C853';
case orderStatuses.MOMENTAL:
return '#00C853';
default:
return '#000';
}
};
</script>
<style scoped lang="scss">
.order {
&__top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 0 10px 5px 10px;
& div {
display: flex;
align-items: center;
gap: 25px;
& p {
display: flex;
align-items: center;
gap: 5px;
&.status {
border-radius: $default_border_radius;
padding: 3px 7px;
}
}
}
&-icon {
transition: 0.2s;
&.active {
transform: rotate(-180deg);
}
}
&-bottom {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 0.2s ease;
overflow: hidden;
&.active {
grid-template-rows: 1fr;
}
& > * {
min-height: 0;
}
& div {
& div {
padding-top: 10px;
border-top: 1px solid $accent;
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 25px;
& div {
padding-top: 0;
border: none;
display: flex;
flex-wrap: wrap;
gap: 10px;
& img {
height: 65px;
border-radius: $default_border_radius;
}
}
& p {
font-weight: 600;
font-size: 20px;
}
}
}
}
}
&__main {
display: flex;
flex-direction: column;
gap: 20px;
}
&__product {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding-bottom: 15px;
border-bottom: 2px solid $accent;
&-left {
display: flex;
align-items: flex-start;
gap: 10px;
& img {
height: 150px;
border-radius: $default_border_radius;
}
& p {
font-size: 18px;
font-weight: 500;
}
}
&-right {
display: flex;
flex-direction: column;
align-items: flex-end;
& h6 {
font-size: 20px;
}
& p {
font-size: 14px;
}
}
}
&__total {
display: flex;
align-items: flex-end;
justify-content: flex-end;
& p {
font-size: 20px;
font-weight: 600;
}
}
}
:deep(.el-collapse-item__header) {
height: fit-content;
padding-block: 10px;
}
</style>

View file

@ -130,7 +130,7 @@ import 'swiper/css';
import 'swiper/css/effect-fade';
import 'swiper/css/pagination'
import {useWishlistOverwrite} from "~/composables/wishlist";
import {useOrderOverwrite} from "~/composables/orders/useOrderOverwrite";
import {useOrderOverwrite} from "~/composables/orders";
import {CURRENCY} from "~/config/constants";
const props = defineProps<{

View file

@ -0,0 +1,88 @@
<template>
<el-skeleton
class="sk"
animated
>
<template #template>
<div class="sk__main">
<el-skeleton-item
variant="p"
class="sk__text"
/>
<el-skeleton-item
variant="p"
class="sk__text"
/>
</div>
<div class="sk__bottom">
<div class="sk__bottom-images">
<el-skeleton-item
variant="image"
class="sk__image"
v-for="idx in 3"
:key="idx"
/>
</div>
<el-skeleton-item
variant="p"
class="sk__price"
/>
</div>
</template>
</el-skeleton>
</template>
<script setup lang="ts">
</script>
<style lang="scss" scoped>
.sk {
width: 100%;
border-radius: $default_border_radius;
background-color: rgba(255, 255, 255, 0.61);
border: 1px solid $accent;
display: flex;
flex-direction: column;
align-items: stretch;
padding: 10px 8px;
&__main {
width: 100%;
display: flex;
align-items: center;
gap: 25px;
padding-bottom: 5px;
}
&__text {
width: 100px;
height: 20px;
border-radius: $default_border_radius;
}
&__bottom {
border-top: 1px solid $accent;
padding-top: 10px;
display: flex;
justify-content: space-between;
&-images {
display: flex;
align-items: center;
gap: 10px;
}
}
&__image {
width: 52px;
height: 65px;
border-radius: $default_border_radius;
}
&__price {
width: 60px;
height: 20px;
}
}
</style>

View file

@ -83,7 +83,7 @@ const emit = defineEmits<{
}>();
const attributesQuery = useRouteQuery<string>('attributes', '');
//TODO: price in filters
const {
selectedMap,
selectedAllMap,

View file

@ -3,13 +3,11 @@ import type { ILoginResponse } from '~/types/api/auth';
import { isGraphQLError } from '~/utils/error';
import { useAppConfig } from '~/composables/config';
import { useLocaleRedirect } from '~/composables/languages';
import { useWishlist } from '~/composables/wishlist';
import { usePendingOrder } from '~/composables/orders';
import { useUserStore } from '~/stores/user';
import { useAppStore } from '~/stores/app';
import {DEFAULT_LOCALE} from "~/config/constants";
import {useNotification} from "~/composables/notification";
import {usePromocodes} from "~/composables/promocodes";
import {useUserBaseData} from "~/composables/user";
export function useLogin() {
const { t } = useI18n();
@ -70,10 +68,14 @@ export function useLogin() {
await checkAndRedirect(authData.user.language);
}
await useWishlist();
await usePendingOrder(authData.user.email);
await usePromocodes();
//TODO: combine three requests
// await useWishlist();
// await useOrders({
// userEmail: authData.user.email,
// status: "PENDING"
// });
// await usePromocodes();
await useUserBaseData(authData.user.email);
}
watch(error, (err) => {

View file

@ -1,13 +1,11 @@
import { REFRESH } from '@/graphql/mutations/auth';
import { useAppConfig } from '~/composables/config';
import { useLocaleRedirect } from '~/composables/languages';
import { useWishlist } from '~/composables/wishlist';
import { usePendingOrder } from '~/composables/orders';
import { useUserStore } from '~/stores/user';
import { isGraphQLError } from '~/utils/error';
import {DEFAULT_LOCALE} from "~/config/constants";
import {useNotification} from "~/composables/notification";
import {usePromocodes} from "~/composables/promocodes";
import {useUserBaseData} from "~/composables/user";
export function useRefresh() {
const { t } = useI18n();
@ -51,10 +49,13 @@ export function useRefresh() {
cookieRefresh.value = data.refreshToken
await useWishlist();
await usePendingOrder(data.user.email);
await usePromocodes();
//TODO: combine three requests
// await useWishlist();
// await useOrders({
// userEmail: data.user.email,
// status: "PENDING"
// });
// await usePromocodes();
await useUserBaseData(data.user.email);
}
watch(error, (err) => {

View file

@ -1,14 +1,15 @@
export function useDate(
iso: string | undefined,
locale: string = 'en-gb'
): string {
if (!iso) return '';
const date = new Date(iso);
const parsedLocale = locale.replace('-', '-').toLocaleUpperCase()
return new Intl.DateTimeFormat(parsedLocale, {
locale: string = 'en-gb',
options: Intl.DateTimeFormatOptions = {
year: 'numeric',
month: 'long',
day: '2-digit'
}).format(date);
}
): string {
if (!iso) return ''
const date = new Date(iso)
const parsedLocale = locale.replace('_', '-').toLowerCase()
return new Intl.DateTimeFormat(parsedLocale, options).format(date)
}

View file

@ -1 +1,2 @@
export * from './usePendingOrder';
export * from './useOrderOverwrite';
export * from './useOrders';

View file

@ -0,0 +1,79 @@
import {GET_ORDERS} from "~/graphql/queries/standalone/orders";
import type {IOrdersResponse} from "~/types";
import {orderStatuses} from "~/config/constants";
interface IOrdersArguments {
userEmail: string,
status?: string,
after?: string,
search: string
}
interface IOrderVars {
status: string,
userEmail: string,
first: number,
after?: string,
search: string
}
export async function useOrders(args: IOrdersArguments) {
const cartStore = useCartStore();
const variables = reactive<IOrderVars>({
status: args.status || '',
userEmail: args.userEmail,
first: 10,
after: args.after,
search: args.search
});
const { pending, data, error, refresh } = await useAsyncQuery<IOrdersResponse>(
GET_ORDERS,
variables
);
const orders = ref(data.value?.orders.edges.filter((order) => order.node.status !== orderStatuses.PENDING) ?? []);
const pageInfo = computed(() => data.value?.orders.pageInfo ?? null);
if (!error.value && data.value?.orders.edges) {
if (args.status === orderStatuses.PENDING) {
cartStore.setCurrentOrders(data.value?.orders.edges[0].node);
}
}
watch(
() => variables.after,
async (newCursor, oldCursor) => {
if (!newCursor || newCursor === oldCursor) return;
await refresh();
const newEdges = data.value?.orders.edges ?? [];
orders.value.push(...newEdges);
}
);
watch(
[
() => variables.status,
() => variables.search
],
async () => {
variables.after = '';
await refresh();
orders.value = data.value?.orders.edges ?? [];
}
);
watch(error, (err) => {
if (err) {
console.error('useOrders error:', err);
}
});
return {
pending,
orders,
pageInfo,
variables
};
}

View file

@ -1,28 +0,0 @@
import {GET_ORDERS} from "~/graphql/queries/standalone/orders";
import type {IOrderResponse} from "~/types";
export async function usePendingOrder(userEmail: string) {
const cartStore = useCartStore();
const { data, error } = await useAsyncQuery<IOrderResponse>(
GET_ORDERS,
{
status: "PENDING",
userEmail
}
);
if (!error.value && data.value?.orders.edges[0].node) {
cartStore.setCurrentOrders(data.value?.orders.edges[0].node);
}
watch(error, (err) => {
if (err) {
console.error('usePendingOrder error:', err);
}
});
return {
};
}

View file

@ -7,6 +7,7 @@ export async function usePromocodes () {
const { data, error } = await useAsyncQuery<IPromocodesResponse>(
GET_PROMOCODES
);
console.log(data.value)
if (!error.value && data.value?.promocodes.edges) {
promocodesStore.setPromocodes(data.value.promocodes.edges);

View file

@ -1,7 +1,7 @@
import { GET_PRODUCTS } from '~/graphql/queries/standalone/products';
import type {IProductResponse} from '~/types';
interface ProdVars {
interface IProdVars {
first: number,
categoriesSlugs: string,
attributes?: string,
@ -19,7 +19,7 @@ export async function useStore(
maxPrice?: number,
productAfter?: string
) {
const variables = reactive<ProdVars>({
const variables = reactive<IProdVars>({
first: 15,
categoriesSlugs: slug,
attributes,

View file

@ -2,3 +2,4 @@ export * from './useUserActivation';
export * from './useAvatarUpload';
export * from './useUserUpdating';
export * from './useDeposit';
export * from './useUserBaseData';

View file

@ -6,17 +6,19 @@ import {useNotification} from "~/composables/notification";
export function useAvatarUpload() {
const { t } = useI18n();
const userStore = useUserStore();
const { mutate, onDone, loading, error } = useMutation<IAvatarUploadResponse>(UPLOAD_AVATAR, {
context: {
hasUpload: true
}}
const { mutate, onDone, loading, error } = useMutation<IAvatarUploadResponse>(
UPLOAD_AVATAR,
// {
// context: { hasUpload: true }
// }
);
async function uploadAvatar(event: Event) {
const file = (event.target as HTMLInputElement).files?.[0];
if (!file) return;
console.log(file)
await mutate({ avatar: file });
await mutate({ file });
}
onDone(({ data }) => {

View file

@ -0,0 +1,41 @@
import type {IUserBaseDataResponse} from "~/types";
import {getUserBaseData} from "~/graphql/queries/combined/userBaseData";
import {orderStatuses} from "~/config/constants";
export async function useUserBaseData(userEmail: string) {
const wishlistStore = useWishlistStore();
const cartStore = useCartStore();
const promocodesStore = usePromocodeStore();
const { document, variables } = getUserBaseData(
{
userEmail,
status: orderStatuses.PENDING
},
);
const { data, error } = await useAsyncQuery<IUserBaseDataResponse>(
document,
variables
);
if (data.value?.wishlists.edges) {
wishlistStore.setWishlist(data.value.wishlists.edges[0].node);
}
if (data.value?.orders.edges) {
cartStore.setCurrentOrders(data.value?.orders.edges[0].node);
}
if (data.value?.promocodes.edges) {
promocodesStore.setPromocodes(data.value.promocodes.edges);
}
watch(error, (err) => {
if (err) {
console.error('useUserBaseData error:', err);
}
});
return {
};
}

View file

@ -87,4 +87,14 @@ export const SUPPORTED_LOCALES: LocaleDefinition[] = [
export const DEFAULT_LOCALE = SUPPORTED_LOCALES.find(locale => locale.default)?.code || 'en-gb';
export const CURRENCY = '$'
export const CURRENCY = '$';
export enum orderStatuses {
PENDING = 'PENDING',
FAILED = 'FAILED',
PAYMENT = 'PAYMENT',
CREATED = 'CREATED',
DELIVERING = 'DELIVERING',
FINISHED = 'FINISHED',
MOMENTAL = 'MOMENTAL'
}

View file

@ -7,6 +7,7 @@ export const ORDER_FRAGMENT = gql`
status
buyTime
humanReadableId
notifications
orderProducts {
edges {
node {

View file

@ -4,9 +4,7 @@ export const PROMOCODE_FRAGMENT = gql`
discount
discountType
endTime
id
startTime
usedOn
uuid
}
`

View file

@ -43,10 +43,10 @@ export const UPDATE_USER = gql`
export const UPLOAD_AVATAR = gql`
mutation uploadAvatar(
$avatar: Upload!
$file: Upload!
) {
uploadAvatar(
avatar: $avatar
file: $file
) {
user {
...User

View file

@ -0,0 +1,18 @@
import combineQuery from 'graphql-combine-query'
import {GET_WISHLIST} from "~/graphql/queries/standalone/wishlist";
import {GET_PROMOCODES} from "~/graphql/queries/standalone/promocodes";
import {GET_ORDERS} from "~/graphql/queries/standalone/orders";
export const getUserBaseData = (
orderVariables?: {
userEmail?: string;
status?: string;
}
) => {
const { document, variables } = combineQuery('getUserBaseData')
.add(GET_WISHLIST)
.add(GET_PROMOCODES)
.add(GET_ORDERS, orderVariables || {})
return { document, variables };
};

View file

@ -3,18 +3,28 @@ import {ORDER_FRAGMENT} from "@/graphql/fragments/orders.fragment.js";
export const GET_ORDERS = gql`
query getOrders(
$status: String!,
$userEmail: String!
$userEmail: String!,
$first: Int,
$after: String,
$search: String,
) {
orders(
status: $status,
orderBy: "-buyTime",
userEmail: $userEmail
userEmail: $userEmail,
first: $first,
after: $after,
search: $search
) {
edges {
node {
...Order
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
${ORDER_FRAGMENT}

View file

@ -25,6 +25,7 @@
},
"fields": {
"search": "Search",
"searchOrder": "Search order",
"name": "Name",
"firstName": "First name",
"lastName": "Last name",
@ -126,6 +127,7 @@
"home": "Home",
"catalog": "Catalog",
"contact": "Contact",
"orders": "Orders",
"wishlist": "Wishlist",
"cart": "Cart",
"settings": "Settings",
@ -163,23 +165,42 @@
"referralTooltip": "You will get a referral link after a successful purchase"
},
"orders": {
"title": "Orders"
"title": "Orders",
"chooseStatus": "Choose status",
"id": "ID",
"price": "Price",
"total": "Total",
"empty": "There are not any orders by this parameters.",
"statuses": {
"all": "All",
"failed": "Failed",
"payment": "Payment",
"created": "Created",
"delivering": "Delivering",
"finished": "Finished",
"momental": "Momental"
},
"searchTooltip": "Enter order id or product name"
},
"wishlist": {
"title": "Wishlist",
"total": "{quantity} items worth {amount}",
"deleteTooltip": "Delete all from wishlist"
"deleteTooltip": "Delete all from wishlist",
"empty": "Your wishlist is empty."
},
"cart": {
"title": "Cart",
"quantity": "Quantity: ",
"total": "Total: "
"total": "Total",
"empty": "Your cart is empty."
},
"balance": {
"title": "Balance"
},
"promocodes": {
"title": "Promocodes"
"title": "Promocodes",
"until": "Until",
"empty": "You don't have any promocodes."
},
"logout": "Logout"
}

View file

@ -0,0 +1,11 @@
export default defineNuxtRouteMiddleware(() => {
const userStore = useUserStore();
const appStore = useAppStore();
const localePath = useLocalePath();
if (!userStore.isAuthenticated) {
appStore.setActiveState('login');
return navigateTo(localePath('/'));
}
})

View file

@ -2,6 +2,7 @@ import { defineNuxtConfig } from 'nuxt/config';
import { i18nConfig } from './config/i18n';
import {fileURLToPath, URL} from "node:url";
import { resolve } from 'node:path';
import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
export default defineNuxtConfig({
ssr: true,
@ -92,6 +93,20 @@ export default defineNuxtConfig({
file: resolve(__dirname, 'pages/index.vue')
}
)
}
},
// 'apollo:client:created'(apolloClient, { key }) {
// console.log(key)
// console.log('log')
// if ( key !== 'default' ) return
// const runtime = useRuntimeConfig()
// const uploadLink = createUploadLink({
// uri: `https://api.${runtime.public.evibesBaseDomain}/graphql/`,
// credentials: 'include',
// headers: {
// 'X-EVIBES-AUTH': useCookie(`${runtime.public.evibesProjectName?.toLowerCase()}-access`).value || ''
// }
// })
// apolloClient.setLink(uploadLink)
// }
}
})

View file

@ -15,7 +15,7 @@
"@vueuse/integrations": "^13.3.0",
"@vueuse/nuxt": "^13.3.0",
"@vueuse/router": "^13.3.0",
"apollo-upload-client": "17.0.0",
"apollo-upload-client": "^18.0.1",
"axios": "^1.9.0",
"graphql-combine-query": "^1.2.4",
"graphql-tag": "^2.12.6",
@ -3674,21 +3674,21 @@
}
},
"node_modules/apollo-upload-client": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-17.0.0.tgz",
"integrity": "sha512-pue33bWVbdlXAGFPkgz53TTmxVMrKeQr0mdRcftNY+PoHIdbGZD0hoaXHvO6OePJAkFz7OiCFUf98p1G/9+Ykw==",
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/apollo-upload-client/-/apollo-upload-client-18.0.1.tgz",
"integrity": "sha512-OQvZg1rK05VNI79D658FUmMdoI2oB/KJKb6QGMa2Si25QXOaAvLMBFUEwJct7wf+19U8vk9ILhidBOU1ZWv6QA==",
"license": "MIT",
"dependencies": {
"extract-files": "^11.0.0"
"extract-files": "^13.0.0"
},
"engines": {
"node": "^12.22.0 || ^14.17.0 || >= 16.0.0"
"node": "^18.15.0 || >=20.4.0"
},
"funding": {
"url": "https://github.com/sponsors/jaydenseric"
},
"peerDependencies": {
"@apollo/client": "^3.0.0",
"@apollo/client": "^3.8.0",
"graphql": "14 - 16"
}
},
@ -6223,17 +6223,32 @@
"license": "MIT"
},
"node_modules/extract-files": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/extract-files/-/extract-files-11.0.0.tgz",
"integrity": "sha512-FuoE1qtbJ4bBVvv94CC7s0oTnKUGvQs+Rjf1L2SJFfS+HTVVjhPFtehPdQ0JiGPqVNfSSZvL5yzHHQq2Z4WNhQ==",
"version": "13.0.0",
"resolved": "https://registry.npmjs.org/extract-files/-/extract-files-13.0.0.tgz",
"integrity": "sha512-FXD+2Tsr8Iqtm3QZy1Zmwscca7Jx3mMC5Crr+sEP1I303Jy1CYMuYCm7hRTplFNg3XdUavErkxnTzpaqdSoi6g==",
"license": "MIT",
"dependencies": {
"is-plain-obj": "^4.1.0"
},
"engines": {
"node": "^12.20 || >= 14.13"
"node": "^14.17.0 || ^16.0.0 || >= 18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/jaydenseric"
}
},
"node_modules/extract-files/node_modules/is-plain-obj": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/extract-zip": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz",

View file

@ -18,7 +18,7 @@
"@vueuse/integrations": "^13.3.0",
"@vueuse/nuxt": "^13.3.0",
"@vueuse/router": "^13.3.0",
"apollo-upload-client": "17.0.0",
"apollo-upload-client": "^18.0.1",
"axios": "^1.9.0",
"graphql-combine-query": "^1.2.4",
"graphql-tag": "^2.12.6",

View file

@ -21,6 +21,7 @@ useHead({
const token = useRouteQuery('token', '');
const uid = useRouteQuery('uid', '');
const referrer = useRouteQuery('referrer', '');
const { activateUser } = useUserActivation();
@ -32,6 +33,10 @@ onMounted( async () => {
if (route.path.includes('reset-password') && token.value && uid.value) {
appStore.setActiveState('new-password');
}
if (referrer.value) {
appStore.setActiveState('register');
}
});
</script>

View file

@ -166,7 +166,7 @@ import 'swiper/css/navigation';
import {Navigation} from "swiper/modules";
import {CURRENCY} from "~/config/constants";
import {useWishlistOverwrite} from "~/composables/wishlist";
import {useOrderOverwrite} from "~/composables/orders/useOrderOverwrite";
import {useOrderOverwrite} from "~/composables/orders";
const route = useRoute();
const {t} = useI18n();

View file

@ -12,35 +12,24 @@
</template>
<script setup lang="ts">
definePageMeta({
middleware: 'auth'
})
</script>
<style lang="scss" scoped>
.profile {
position: relative;
padding-top: 50px;
height: calc(100vh - 125px);
display: flex;
& .container {
display: flex;
flex: 1;
min-height: 0;
}
&__wrapper {
flex: 1;
display: flex;
align-items: stretch;
gap: 100px;
min-height: 0;
}
&__inner {
flex: 1;
width: 100%;
min-height: 0;
display: flex;
}
}
</style>

View file

@ -3,37 +3,41 @@
<div class="cart__top">
<div class="cart__top-left">
<p><span>{{ t('profile.cart.quantity') }}</span> {{ productsInCartQuantity }}</p>
<p><span>{{ t('profile.cart.total') }}</span> {{ totalPrice }}</p>
<p><span>{{ t('profile.cart.total') }}: </span> {{ totalPrice }}{{ CURRENCY }}</p>
</div>
<ui-button class="cart__top-button">{{ t('buttons.checkout') }}</ui-button>
</div>
<div class="cart__list">
<cards-product
v-for="product in productsInCart"
:key="product.node.uuid"
:product="product.node.product"
:isList="true"
:isToolsVisible="true"
/>
<div class="cart__list-inner" v-if="productsInCart.length">
<cards-product
v-for="product in productsInCart"
:key="product.node.uuid"
:product="product.node.product"
:isList="true"
:isToolsVisible="true"
/>
</div>
<p class="cart__empty">{{ t('profile.cart.empty') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import {usePageTitle} from "~/composables/utils";
import {CURRENCY} from "~/config/constants";
const {t} = useI18n();
const cartStore = useCartStore();
const productsInCart = computed(() => {
return cartStore.currentOrder.orderProducts ? cartStore.currentOrder.orderProducts.edges : [];
return cartStore.currentOrder ? cartStore.currentOrder.orderProducts.edges : [];
});
const totalPrice = computed(() => {
return cartStore.currentOrder.totalPrice ? cartStore.currentOrder.totalPrice : [];
return cartStore.currentOrder ? cartStore.currentOrder.totalPrice : 0;
});
const productsInCartQuantity = computed(() => {
let count = 0;
cartStore.currentOrder.orderProducts?.edges.forEach((el) => {
cartStore.currentOrder?.orderProducts?.edges.forEach((el) => {
count = count + el.node.quantity;
});
@ -51,8 +55,6 @@ setPageTitle(t('breadcrumbs.cart'));
display: flex;
flex-direction: column;
gap: 50px;
flex: 1;
min-height: 0;
&__top {
width: 100%;
@ -78,13 +80,18 @@ setPageTitle(t('breadcrumbs.cart'));
width: 100%;
padding: 20px;
background-color: $white;
display: flex;
flex-direction: column;
gap: 20px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
border-radius: $default_border_radius;
flex: 1;
overflow-y: auto;
&-inner {
display: flex;
flex-direction: column;
gap: 20px;
}
}
&__empty {
font-weight: 600;
}
}
</style>

View file

@ -1,15 +1,228 @@
<template>
<div class="settings">
<p>orders</p>
<div class="orders">
<div class="orders__top">
<h2>{{ t('profile.orders.title') }}</h2>
<el-tooltip
:visible="isSearchFocused"
:content="t('profile.orders.searchTooltip')"
placement="top-start"
>
<form class="orders__search" @submit.prevent="submitSearch">
<input
type="text"
inputmode="search"
v-model="searchInput"
:placeholder="t('fields.searchOrder')"
@focus="isSearchFocused = true"
@blur="isSearchFocused = false"
>
<button type="submit">
<icon name="tabler:search" size="16" />
</button>
</form>
</el-tooltip>
<el-select
v-model="status"
size="large"
style="width: 240px"
:placeholder="t('profile.orders.chooseStatus')"
>
<el-option
v-for="item in options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</div>
<div class="orders__inner" v-if="orders.length">
<el-collapse
v-model="collapse"
class="orders__list"
>
<cards-order
v-for="order in orders"
:key="order.node.uuid"
:order="order.node"
/>
</el-collapse>
<div class="orders__list" v-if="pending">
<skeletons-cards-order
v-for="idx in 5"
:key="idx"
/>
</div>
<div class="orders__list-observer" ref="observer"></div>
</div>
<div class="orders__empty" v-if="!orders.length && !pending">
<p>{{ t('profile.orders.empty') }}</p>
</div>
</div>
</template>
<script setup lang="ts">
import {usePageTitle} from "~/composables/utils";
import {useOrders} from "~/composables/orders";
import {CURRENCY, orderStatuses} from "~/config/constants";
import {useDate} from "~/composables/date";
import {useRouteQuery} from "@vueuse/router";
const {t} = useI18n();
const userStore = useUserStore();
const userEmail = computed(() => {
return userStore.user ? userStore.user.email : '';
});
const status = useRouteQuery<string>('status', '');
const search = useRouteQuery<string>('search', '');
const searchInput = ref<string>('');
const { pending, orders, pageInfo, variables } = await useOrders({
userEmail: userEmail.value,
status: status.value,
search: search.value,
after: ''
});
const options = [
{
value: '',
label: t('profile.orders.statuses.all')
},
{
value: orderStatuses.FAILED,
label: t('profile.orders.statuses.failed')
},
{
value: orderStatuses.PAYMENT,
label: t('profile.orders.statuses.payment')
},
{
value: orderStatuses.CREATED,
label: t('profile.orders.statuses.created')
},
{
value: orderStatuses.DELIVERING,
label: t('profile.orders.statuses.delivering')
},
{
value: orderStatuses.FINISHED,
label: t('profile.orders.statuses.finished')
},
{
value: orderStatuses.MOMENTAL,
label: t('profile.orders.statuses.momental')
}
];
const collapse = ref([]);
const isSearchFocused = ref<boolean>(false);
const observer = ref(null);
useIntersectionObserver(
observer,
async ([{ isIntersecting }]) => {
if (isIntersecting && pageInfo.value?.hasNextPage && !pending.value) {
variables.after = pageInfo.value.endCursor;
}
},
);
const submitSearch = () => {
search.value = searchInput.value;
variables.search = searchInput.value || '';
};
watch(status, newVal => {
variables.status = newVal || '';
});
const { setPageTitle } = usePageTitle();
setPageTitle(t('breadcrumbs.orders'));
</script>
<style lang="scss" scoped>
.settings {
.orders {
width: 100%;
display: flex;
flex-direction: column;
gap: 50px;
flex: 1;
min-height: 0;
&__top {
width: 100%;
background-color: $white;
padding: 20px;
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 50px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
border-radius: $default_border_radius;
}
&__search {
border-radius: $default_border_radius;
border: 1px solid #dedede;
overflow: hidden;
background-color: $white;
display: flex;
align-items: center;
width: 100%;
& input {
background-color: transparent;
height: 100%;
padding-inline: 15px;
width: 100%;
color: #606266;
}
& button {
height: 100%;
padding-inline: 10px;
cursor: pointer;
border-radius: $default_border_radius;
background-color: rgba($accent, 0.2);
border: 1px solid $accent;
display: grid;
place-items: center;
transition: 0.2s;
color: $accent;
@include hover {
background-color: $accent;
color: $white;
}
}
}
&__list {
display: flex;
flex-direction: column;
gap: 30px;
&-observer {
background-color: transparent;
width: 100%;
height: 10px;
}
}
&__empty {
background-color: $white;
padding: 20px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
border-radius: $default_border_radius;
& p {
font-weight: 600;
}
}
}
</style>

View file

@ -1,29 +1,38 @@
<template>
<div class="promocodes">
<h2>{{ t('profile.promocodes.title') }}</h2>
<div class="promocodes__list">
<div class="promocodes__list" v-if="promocodes?.length">
<div
class="promocodes__item"
v-for="promocode in promocodes"
:key="promocode.node.uuid"
>
<icon
name="material-symbols:content-copy"
size="20"
class="promocodes__item-button"
@click="copyCode(promocode.node.code)"
/>
<p>{{ promocode.node.code }}</p>
<div class="promocodes__item-left">
<icon
name="material-symbols:content-copy"
size="20"
class="promocodes__item-button"
@click="copyCode(promocode.node.code)"
/>
<p>{{ promocode.node.code }}</p>
</div>
<div class="promocodes__item-right">
<p class="promocodes__item-text">{{ promocode.node.discount }} {{ promocode.node.discountType === 'percent' ? '%' : CURRENCY }}</p>
<div class="promocodes__item-expire">{{ t('profile.promocodes.until') }} {{ useDate(promocode.node.endTime, locale) }}</div>
</div>
</div>
</div>
<p class="promocodes__empty">{{ t('profile.promocodes.empty') }}</p>
</div>
</template>
<script setup lang="ts">
import {usePageTitle} from "~/composables/utils";
import {useNotification} from "~/composables/notification/index.js";
import {CURRENCY} from "~/config/constants";
import {useDate} from "~/composables/date";
const {t} = useI18n();
const {t, locale} = useI18n();
const promocodesStore = usePromocodeStore();
const promocodes = computed(() => promocodesStore.promocodes);
@ -40,7 +49,7 @@ const copyCode = (code: string) => {
console.error(err);
});
};
// TODO: display more info about promo
// TODO: display time for expire date
const { setPageTitle } = usePageTitle();
setPageTitle(t('breadcrumbs.promocodes'));
@ -65,10 +74,20 @@ setPageTitle(t('breadcrumbs.promocodes'));
&__item {
border-radius: $default_border_radius;
border: 1px solid $accent;
padding: 7px 15px;
display: flex;
align-items: center;
gap: 20px;
align-items: stretch;
justify-content: space-between;
&-left {
display: flex;
align-items: center;
gap: 20px;
padding: 7px 25px;
& p {
font-weight: 600;
}
}
&-button {
cursor: pointer;
@ -80,9 +99,31 @@ setPageTitle(t('breadcrumbs.promocodes'));
}
}
& p {
font-weight: 600;
&-right {
display: flex;
align-items: center;
gap: 20px;
}
&-text {
font-size: 18px;
font-weight: 700;
}
&-expire {
height: 100%;
background-color: $accent;
padding-inline: 15px;
display: grid;
place-items: center;
color: $white;
}
}
&__empty {
margin-top: 50px;
font-weight: 600;
}
}
</style>

View file

@ -24,7 +24,7 @@
</el-tooltip>
</div>
<div class="wishlist__list">
<div class="wishlist__list-inner">
<div class="wishlist__list-inner" v-if="productsInWishlist.length">
<div
class="wishlist__item"
v-for="product in productsInWishlist"
@ -43,6 +43,7 @@
/>
</div>
</div>
<p class="wishlist__empty">{{ t('profile.wishlist.empty') }}</p>
</div>
</div>
</template>
@ -112,8 +113,6 @@ setPageTitle(t('breadcrumbs.wishlist'));
display: flex;
flex-direction: column;
gap: 50px;
flex: 1;
min-height: 0;
&__top {
width: 100%;
@ -162,8 +161,6 @@ setPageTitle(t('breadcrumbs.wishlist'));
padding: 20px;
box-shadow: 0 0 20px 2px rgba(0, 0, 0, 0.2);
border-radius: $default_border_radius;
flex: 1;
overflow-y: auto;
&-inner {
display: flex;
@ -178,5 +175,9 @@ setPageTitle(t('breadcrumbs.wishlist'));
align-items: flex-start;
gap: 5px;
}
&__empty {
font-weight: 600;
}
}
</style>

View file

@ -0,0 +1,21 @@
import createUploadLink from "apollo-upload-client/createUploadLink.mjs";
export default defineNuxtPlugin(async () => {
const { client } = useApolloClient()
if (client.name !== 'default') return
const runtime = useRuntimeConfig()
const token = useCookie(
`${runtime.public.evibesProjectName?.toLowerCase()}-access`
).value || ''
const uploadLink = createUploadLink({
uri: `https://api.${runtime.public.evibesBaseDomain}/graphql/`,
credentials: 'include',
headers: { 'X-EVIBES-AUTH': token }
})
client.setLink(uploadLink)
console.log('✅ apollo link replaced to uploadLink')
})

View file

@ -1,5 +1,6 @@
import type {IUser} from "~/types";
import {useAppConfig} from "~/composables/config";
import {orderStatuses} from "~/config/constants";
export const useUserStore = defineStore('user', () => {
const { COOKIES_ACCESS_TOKEN_KEY } = useAppConfig();
@ -15,7 +16,7 @@ export const useUserStore = defineStore('user', () => {
const isAuthenticated = computed(() => Boolean(cookieAccess.value && user.value));
const finishedOrdersQuantity = computed(() => {
return user.value?.orders.filter((order) => order.status === 'FINISHED').length || 0;
return user.value?.orders.filter((order) => order.status === orderStatuses.FINISHED).length || 0;
});
const setUser = (data: IUser | null) => {

View file

@ -1,10 +1,14 @@
import type {IOrder} from "~/types";
export interface IOrderResponse {
export interface IOrdersResponse {
orders: {
edges: {
node: IOrder
}[]
node: IOrder
}[],
pageInfo: {
hasNextPage: boolean
endCursor: string
}
}
}

View file

@ -1,4 +1,9 @@
import type {IUser} from "~/types";
import type {
IOrdersResponse,
IPromocodesResponse,
IUser,
IWishlistResponse
} from "~/types";
export interface IUserResponse {
updateUser: {
@ -6,6 +11,11 @@ export interface IUserResponse {
}
}
export interface IUserBaseDataResponse
extends IWishlistResponse,
IOrdersResponse,
IPromocodesResponse {}
export interface IUserActivationResponse {
activateUser: {
success: boolean

View file

@ -6,6 +6,7 @@ export interface IOrder {
status: string,
buyTime: string | null,
humanReadableId: string,
notifications: string | null,
orderProducts: {
edges: {
node: {

View file

@ -3,8 +3,6 @@ export interface IPromocode {
discount: string,
discountType: string,
endTime: string,
id: string,
startTime: string,
usedOn: string,
uuid: string
}