This commit is contained in:
Parsa Nazer
2025-02-02 22:50:05 +03:30
21 changed files with 2554 additions and 383 deletions
+1
View File
@@ -22,3 +22,4 @@ node_modules
.env
.env.*
!.env.example
/test-results/.last-run.json
+6 -6
View File
@@ -55,27 +55,27 @@
/* TYPE PARAGRAPH */
@utility typo-p-2xl {
@apply text-[24px] leading-[40px] font-light ;
@apply text-[24px] leading-[42px] font-light ;
}
@utility typo-p-xl {
@apply text-[20px] leading-[32px] font-light ;
@apply text-[20px] leading-[34px] font-light ;
}
@utility typo-p-lg {
@apply text-[18px] leading-[32px] font-light ;
@apply text-[18px] leading-[34px] font-light ;
}
@utility typo-p-md {
@apply text-[16px] leading-[28px] font-light ;
@apply text-[16px] leading-[30px] font-light ;
}
@utility typo-p-sm {
@apply text-[14px] leading-[24px] font-light ;
@apply text-[14px] leading-[26px] font-light ;
}
@utility typo-p-xs {
@apply text-[12px] leading-[16px] font-light ;
@apply text-[12px] leading-[18px] font-light ;
}
/* TYPO LABEL */
@@ -0,0 +1,63 @@
<script lang="ts" setup>
// import
import Masonry from "masonry-layout";
// type
type Props = {
articles: Article[],
}
// props
const props = defineProps<Props>();
const { articles } = toRefs(props);
// state
onMounted(() => {
new Masonry(".masonry-articles-container", {
itemSelector: ".grid-item",
columnWidth: ".grid-sizer",
percentPosition: true,
gutter: ".gutter-sizer"
});
});
</script>
<template>
<div class="masonry-articles-container w-full">
<div class="grid-sizer"></div>
<div class="gutter-sizer"></div>
<BlogPost
v-for="article in articles"
:key="article.id"
class="grid-item"
:image="article.cover_image"
:description="article.summery"
:title="article.title"
:comments="2"
:id="article.id"
:date="article.created_at"
tag="تگ ندارد"
/>
</div>
</template>
<style>
.grid-sizer,
.grid-item {
margin-bottom: 24px;
width: 48%;
}
.gutter-sizer {
width: 4%;
}
</style>
+79 -81
View File
@@ -3,12 +3,11 @@
// types
type Props = {
id: number;
tag: string;
date: string;
comments: number;
title: string;
description: string;
link: string;
variant?: "sm" | "lg";
image: string,
}
@@ -23,96 +22,95 @@ const {} = toRefs(props);
</script>
<template>
<div
:class="variant === 'lg' ? 'rounded-150 overflow-hidden' : ''"
class="group max-h-[700px] h-[700px] relative"
>
<Tag
v-if="variant === 'lg'"
class="bg-success-500 absolute left-10 top-10 z-20"
>
اسپیکر
</Tag>
<NuxtLink :to="`/article/${id}`">
<div
v-if="variant === 'sm'"
class="h-[350px] rounded-150 overflow-hidden relative"
:class="variant === 'lg' ? 'h-[600px] rounded-150 overflow-hidden' : 'h-fit'"
class="group w-full relative"
>
<Tag
class="bg-success-500 absolute z-20 left-6 top-6"
v-if="variant === 'lg'"
class="bg-success-500 absolute left-10 top-10 z-20"
>
اسپیکر
</Tag>
<div
v-if="variant === 'sm'"
class="h-[350px] rounded-150 overflow-hidden relative"
>
<Tag
class="bg-success-500 absolute z-20 left-6 top-6"
>
اسپیکر
</Tag>
<img
:src="image"
class="group-hover:scale-105 transition-transform duration-200 absolute size-full object-cover z-10"
alt=""
/>
</div>
<div
:class="variant === 'lg' ? 'absolute px-10' : 'invert mt-8'"
class="bottom-10 flex flex-col gap-6 z-20"
>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Icon
name="ci:comment"
size="18"
class="**:stroke-white"
/>
<span class="typo-p-sm text-white">
۰ نظر
</span>
</div>
<div class="flex items-center gap-2">
<Icon
name="ci:calendar"
size="18"
class="**:stroke-white"
/>
<span class="typo-p-sm text-white">
۳۱ مهر ۱۴۰۳
</span>
</div>
</div>
<div class="flex gap-4 flex-col">
<span
:class="variant === 'lg' ? 'typo-h-5' : 'typo-h-6'"
class="text-white"
>
{{ title }}
</span>
<p
:class="variant === 'lg' ? 'typo-h-4' : 'typo-h-6 text-slate-500'"
class="typo-p-md text-white text-justify"
v-html="description"
/>
</div>
<NuxtLink :to="`/article/${id}`" class="underline text-white typo-p-md">
بیشتر بخوانید...
</NuxtLink>
</div>
<img
v-if="variant === 'lg'"
:src="image"
class="group-hover:scale-105 transition-transform duration-200 absolute size-full object-cover z-10"
alt=""
/>
<div
v-if="variant === 'lg'"
class="w-full h-full bg-linear-to-t from-black to-transparent absolute inset-0 z-15"
/>
</div>
<div
:class="variant === 'lg' ? 'absolute px-10' : 'invert mt-8'"
class="bottom-10 flex flex-col gap-6 z-20"
>
<div class="flex items-center gap-6">
<div class="flex items-center gap-2">
<Icon
name="ci:comment"
size="18"
class="**:stroke-white"
/>
<span class="typo-p-sm text-white">
۰ نظر
</span>
</div>
<div class="flex items-center gap-2">
<Icon
name="ci:calendar"
size="18"
class="**:stroke-white"
/>
<span class="typo-p-sm text-white">
۳۱ مهر ۱۴۰۳
</span>
</div>
</div>
<div class="flex gap-4 flex-col">
<span
:class="variant === 'lg' ? 'typo-h-4' : 'typo-h-6'"
class="text-white"
>
برسی آیفون ۱۶ پرومکس
</span>
<p
:class="variant === 'lg' ? 'typo-h-4' : 'typo-h-6 text-slate-500'"
class="typo-p-md text-white text-justify"
>
نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد.
نیاز و کاربردهای متنوع با هدف بهبود ابزارهای کاربردی می باشد.
کتابهای زیادی در شصت و سه درصد گذشته.
</p>
</div>
<span class="underline text-white typo-p-md">
بیشتر بخوانید...
</span>
</div>
<img
v-if="variant === 'lg'"
:src="image"
class="group-hover:scale-105 transition-transform duration-200 absolute size-full object-cover z-10"
alt=""
/>
<div
v-if="variant === 'lg'"
class="w-full h-full bg-linear-to-t from-black to-transparent absolute inset-0 z-15"
/>
</div>
</NuxtLink>
</template>
@@ -44,9 +44,7 @@ const filters = computed(() => {
const { data: categories, suspense } = useGetCategories();
await useAsyncData(async () => {
await suspense();
});
await suspense();
const { isPending: productsIsPending } = useGetProducts(filters);
+21 -9
View File
@@ -10,6 +10,7 @@ import useHomeData from "~/composables/api/home/useHomeData";
const { data: homeData } = useHomeData();
const swiper_instance = ref<SwiperClass | null>(null);
const isMuted = ref(true);
// methods
@@ -37,18 +38,29 @@ const onChange = (swiper: SwiperClass) => {
@slide-change="onChange"
>
<SwiperSlide
v-for="slide in homeData!.sliders"
v-for="(slide, index) in homeData!.sliders"
:key="slide.id"
>
<div class="relative w-full rounded-200 h-[80svh] overflow-hidden">
<video
v-if="!!slide.video"
muted
autoplay
loop
class="absolute inset-0 size-full object-cover"
:src="slide.video"
/>
<template v-if="!!slide.video">
<button
@click="isMuted = !isMuted"
class="transition-all hover:invert cursor-pointer flex-center hover:scale-110 size-[50px] border border-white hover:border-transparent rounded-full absolute z-20 top-10 right-20 bg-black"
>
<Icon
:name="isMuted ? 'bi:volume-mute-fill' : 'bi:volume-up-fill'"
class="text-white"
size="24px"
/>
</button>
<video
:muted="swiper_instance?.realIndex !== index ? true : isMuted"
autoplay
loop
class="absolute inset-0 size-full object-cover"
:src="slide.video"
/>
</template>
<img
v-else
class="absolute inset-0 size-full object-cover"
+10 -49
View File
@@ -1,13 +1,15 @@
<script setup lang="ts">
// types
// state
type Props = {}
import useGetArticles from "~/composables/api/blog/useGetArticles";
// props
const page = ref(1);
const { data: articles, suspense } = useGetArticles(page);
const props = defineProps<Props>();
const {} = toRefs(props);
// ssr
await suspense();
</script>
@@ -23,49 +25,8 @@ const {} = toRefs(props);
</Button>
</NuxtLink>
</div>
<div class="flex gap-12">
<div class="flex-1 flex flex-col gap-12">
<BlogPost
image="/img/blog-1.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
/>
<BlogPost
image="/img/blog-2.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
/>
</div>
<div class="flex-[0.8] flex flex-col">
<BlogPost
image="/img/blog-3.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
variant="sm"
/>
<BlogPost
image="/img/blog-4.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
variant="sm"
/>
</div>
</div>
<ClientOnly>
<ArticlesList :articles="articles!.results" />
</ClientOnly>
</section>
</template>
+14 -2
View File
@@ -3,6 +3,7 @@
// import
import useGetProduct from "~/composables/api/product/useGetProduct";
import { sanitize } from "isomorphic-dompurify";
// state
@@ -14,6 +15,9 @@ const { data: product } = useGetProduct(id);
const quantity = ref(1);
const selectedSlide = ref(0);
// computed
const slides = computed(() => {
return [
{
@@ -30,6 +34,11 @@ const slides = computed(() => {
}
];
});
const sanitizedProductDescription = computed(() => {
return sanitize(product.value!.description);
});
</script>
<template>
@@ -47,16 +56,18 @@ const slides = computed(() => {
<Rating />
</div>
<p
<div
class="py-8 typo-p-md text-slate-500 text-justify [&_a]:text-blue-400 [&_strong]:font-bold [&_u]:text-red-400"
v-html="product!.description"
v-html="sanitizedProductDescription"
/>
<div class="w-full flex flex-col gap-6 mt-4">
<RemainQuantity
:maxQuantity="product!.in_stock"
:quantity="quantity"
/>
<div class="w-full flex gap-3 flex-col">
<div class="w-full flex gap-3">
<Button class="w-full rounded-full" end-icon="ci:plus">
@@ -71,6 +82,7 @@ const slides = computed(() => {
همین الان بخر
</Button>
</div>
<InfoCard />
<Share />
</div>
@@ -0,0 +1,29 @@
// imports
import { useQuery } from "@tanstack/vue-query";
import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetArticleResponse = Article;
const useGetArticle = (id: number | string | undefined) => {
// state
const { $axios: axios } = useNuxtApp();
// methods
const handleGetArticle = async () => {
const { data } = await axios.get<GetArticleResponse>(`${API_ENDPOINTS.blog.article}/${id}`);
return data;
};
return useQuery({
queryKey: [QUERY_KEYS.article, id],
queryFn: () => handleGetArticle()
});
};
export default useGetArticle;
@@ -5,11 +5,11 @@ import { API_ENDPOINTS, QUERY_KEYS } from "~/constants";
// types
export type GetArticlesResponse = ApiPaginated<UserComment>;
export type GetArticlesResponse = ApiPaginated<Article>;
const useGetArticles = (
page: Ref<number>,
search: Ref<string>
search?: Ref<string>
) => {
// state
@@ -23,7 +23,7 @@ const useGetArticles = (
params: {
offset: (page.value * 10) - 10,
limit: 10,
search: search.value.length > 0 ? search.value : undefined,
search: search ? (search.value.length > 0 ? search.value : undefined) : undefined,
}
});
return data;
+2 -1
View File
@@ -58,7 +58,8 @@ export default defineNuxtConfig({
"@nuxt/icon",
"reka-ui/nuxt",
"@vueuse/nuxt",
"@formkit/auto-animate/nuxt"
"@formkit/auto-animate/nuxt",
'@nuxt/test-utils/module'
],
runtimeConfig: {
+2131 -143
View File
File diff suppressed because it is too large Load Diff
+11 -1
View File
@@ -7,6 +7,7 @@
"build": "nuxt build",
"dev": "nuxt dev",
"dev-o": "nuxt dev -- -o",
"test": "vitest",
"generate": "nuxt generate",
"preview": "nuxt preview",
"postinstall": "nuxt prepare"
@@ -25,6 +26,8 @@
"axios": "^1.7.9",
"fast-average-color": "^9.4.0",
"gsap": "^3.12.5",
"isomorphic-dompurify": "^2.21.0",
"masonry-layout": "^4.2.2",
"nuxt": "^3.14.1592",
"reka-ui": "^1.0.0-alpha.6",
"swiper": "^11.1.15",
@@ -35,9 +38,16 @@
"vue-skeletor": "^1.0.6"
},
"devDependencies": {
"@nuxt/test-utils": "^3.15.4",
"@tailwindcss/postcss": "^4.0.0-beta.5",
"@types/masonry-layout": "^4.2.8",
"@vue/test-utils": "^2.4.6",
"autoprefixer": "^10.4.20",
"happy-dom": "^16.8.1",
"msw": "^2.7.0",
"playwright-core": "^1.50.1",
"postcss": "^8.4.49",
"tailwindcss": "^4.0.0-beta.5"
"tailwindcss": "^4.0.0-beta.5",
"vitest": "^3.0.4"
}
}
+115
View File
@@ -0,0 +1,115 @@
<script lang="ts" setup>
// import
import { sanitize } from "isomorphic-dompurify";
import useGetArticle from "~/composables/api/blog/useGetArticle";
// state
const route = useRoute();
const id = route.params.id as string | undefined;
const { data: article, suspense } = useGetArticle(id);
// ssr
const response = await suspense();
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Error in categories page prefetch`
});
}
// computed
const sanitizedArticleContent = computed(() => {
return sanitize(article.value!.content);
});
const sanitizedArticleSummery = computed(() => {
return sanitize(article.value!.summery);
});
</script>
<template>
<div class="container">
<div class="w-full h-[80svh] rounded-3xl relative overflow-hidden">
<img class="absolute object-cover size-full" :alt="article!.title" :src="article!.cover_image" />
<div class="absolute bg-linear-to-t from-black/75 to-transparent size-full" />
<div class="absolute pl-10 right-10 bottom-10 flex flex-col gap-6">
<h1 class="typo-h-4 text-white pl-8">
{{ article!.title }}
</h1>
<div
class="typo-p-lg text-slate-200 mb-6 text-justify w-[70%]"
v-html="sanitizedArticleSummery"
/>
<div class="flex items-center justify-between">
<div class="flex items-center gap-4">
<div
class="w-fit pr-2 pl-5 h-[50px] rounded-full flex items-center justify-center gap-3 bg-white">
<div
class="relative flex items-center justify-center rounded-full overflow-hidden size-[35px]">
<img
class="size-full object-cover absolute"
:src="article!.author.profile_photo"
alt="article-author"
/>
</div>
<span class="typo-label-sm">
{{ article!.author.full_name }}
</span>
</div>
<div
class="w-fit pr-4 pl-5 h-[50px] rounded-full flex items-center justify-center gap-2 border-[1.5px] border-white text-white">
<span class="typo-label-sm mt-0.5">
دسته بندی موبایل
</span>
</div>
</div>
<div class="flex items-center gap-4">
<div
class="w-fit pr-4 pl-5 h-[50px] rounded-full flex items-center justify-center gap-2 border-[1.5px] border-white text-white">
<Icon name="ci:calendar" size="24px" class="**:stroke-white" />
<span class="typo-label-sm mt-0.5">
۲۴ مهر 1403
</span>
</div>
<div
class="w-fit pr-4 pl-5 h-[50px] rounded-full flex items-center justify-center gap-2 border-[1.5px] border-white text-white">
<Icon name="ci:eye-open" size="24px" class="**:stroke-white" />
<span class="typo-label-sm mt-0.5">
{{ article!.views }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="flex gap-4 mt-8">
<div
class="p-8 flex-1 text-zinc-800 flex flex-col gap-6 [&_p,ul]:text-zinc-500 [&_h1]:typo-h-4 [&_h2]:typo-h-5 [&_h3]:typo-h-6 [&_p]:typo-p-md [&_ul]:list-disc [&_ul]:typo-p-md [&_ul]:space-y-2"
v-html="sanitizedArticleContent"
/>
<aside class="mt-8 p-8 h-fit bg-slate-100 w-[400px] sticky top-4 rounded-3xl">
asdsa
</aside>
</div>
</div>
</template>
+15 -53
View File
@@ -3,6 +3,7 @@
// import
import useGetArticles from "~/composables/api/blog/useGetArticles";
import ArticlesList from "~/components/articles/ArticlesList.vue";
// state
@@ -14,17 +15,15 @@ const { data: articles, suspense } = useGetArticles(page, debouncedSearch);
// ssr
await useAsyncData(async () => {
const response = await suspense();
const response = await suspense();
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Error in categories page prefetch`
});
}
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Error in categories page prefetch`
});
}
});
</script>
<template>
@@ -50,50 +49,13 @@ await useAsyncData(async () => {
</template>
</Input>
</div>
<div class="flex gap-12">
<div class="flex-1 flex flex-col gap-12">
<BlogPost
image="/img/blog-1.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
/>
<BlogPost
image="/img/blog-2.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
/>
</div>
<div class="flex-[0.8] flex flex-col">
<BlogPost
image="/img/blog-3.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
variant="sm"
/>
<BlogPost
image="/img/blog-4.jpeg"
description="aaasd"
title="asd"
:comments="2"
link="#"
date="2020-06-10"
tag="asdsa"
variant="sm"
/>
</div>
</div>
<!-- This is for masonry js package -->
<ClientOnly>
<ArticlesList :articles="articles!.results" />
</ClientOnly>
<div class="w-full flex-center pt-24 pb-10">
<Pagination :items="[]" :total="100" />
</div>
+8 -11
View File
@@ -31,17 +31,14 @@ const filteredCategories = computed(() => {
// ssr
await useAsyncData(async () => {
const response = await suspense();
const response = await suspense();
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Error in categories page prefetch`
});
}
});
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Error in categories page prefetch`
});
}
</script>
@@ -79,7 +76,7 @@ await useAsyncData(async () => {
>
<div class="w-full flex items-center justify-between gap-8">
<div class="flex items-center gap-2">
<!-- <img :src="mainCategory.icon" alt="" class="w-[30px] opacity-50" />-->
<!-- <img :src="mainCategory.icon" alt="" class="w-[30px] opacity-50" />-->
<span class="typo-h-5">
{{ mainCategory.name }}
</span>
+7 -9
View File
@@ -10,16 +10,14 @@ const { suspense } = useHomeData();
// ssr
await useAsyncData(async () => {
const response = await suspense();
const response = await suspense();
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Landing error : ${response.error.message}`,
})
}
});
if (response.isError) {
throw createError({
statusCode: 500,
statusMessage: `Landing error : ${response.error.message}`
});
}
</script>
+10 -12
View File
@@ -9,22 +9,20 @@ const route = useRoute();
const id = route.params.id as string | undefined;
const page = ref(1);
const { suspense : suspenseProduct } = useGetProduct(id);
const { suspense : suspenseComments} = useGetComments(id, page);
const { suspense: suspenseProduct } = useGetProduct(id);
const { suspense: suspenseComments } = useGetComments(id, page);
// ssr
await useAsyncData(async () => {
const productResponse = await suspenseProduct();
const commentsResponse = await suspenseComments();
const productResponse = await suspenseProduct();
const commentsResponse = await suspenseComments();
if (productResponse.isError || commentsResponse.isError) {
throw createError({
statusCode: 404,
statusMessage: `error : product ${id} prefetch error`
});
}
});
if (productResponse.isError || commentsResponse.isError) {
throw createError({
statusCode: 404,
statusMessage: `error : product ${id} prefetch error`
});
}
</script>
+2
View File
@@ -158,6 +158,7 @@ const resetForm = () => {
class="flex items-center gap-2 w-full"
>
<Input
data-testid="phone-input"
class="w-full"
v-model="loginInfo.phone"
placeholder="9380123456"
@@ -185,6 +186,7 @@ const resetForm = () => {
/>
<Button
data-testid="send-otp-code-button"
v-if="!showOtp"
class="rounded-full w-full mt-4"
type="submit"
+19
View File
@@ -43,6 +43,25 @@ declare global {
meta_rating: number | null;
};
type Article = {
"id": number,
"title": string,
"slug": string,
"content": string,
"summery": string,
"created_at": string,
"updated_at": string,
"cover_image": string,
"views": number,
"meta_description": string,
"meta_keywords": string,
"author": {
"full_name": string,
"profile_photo": string
},
"category": number
}
type UserComment = {
"id": number,
"content": string,
+7
View File
@@ -0,0 +1,7 @@
import { defineVitestConfig } from "@nuxt/test-utils/config";
export default defineVitestConfig({
test: {
environment: "nuxt",
},
});