feat: polish admin UI, phone login, and product details

Switch admin auth to mobile, restyle shell/login with Vuesax icons, and add product detail fields plus public product pages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
S. Esmaeili
2026-10-22 17:18:17 +03:30
co-authored by Cursor
parent 789d224ea0
commit 49f30a96b1
54 changed files with 3632 additions and 574 deletions
+49
View File
@@ -0,0 +1,49 @@
<script setup lang="ts">
/**
* Page shell inside the white panel:
* - optional sticky head (toolbar / filters)
* - body: scrolls by default, or locks height when `lockBody` (table fills + scrolls internally)
*/
defineProps<{ lockBody?: boolean }>();
</script>
<template>
<div class="ap">
<header v-if="$slots.head" class="ap__head">
<slot name="head" />
</header>
<div class="ap__body" :class="{ 'ap__body--locked': lockBody }">
<slot />
</div>
</div>
</template>
<style scoped>
.ap {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
.ap__head {
flex-shrink: 0;
padding: 16px 16px 12px;
border-bottom: 1px solid #eef1f6;
background: #ffffff;
}
.ap__body {
flex: 1;
min-height: 0;
overflow: auto;
padding: 16px;
}
.ap__body--locked {
display: flex;
flex-direction: column;
overflow: hidden;
}
</style>
+4 -4
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { DeleteOutlined, PictureOutlined } from "@ant-design/icons-vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
import type { MediaDto } from "@imen/shared";
import { ref } from "vue";
import MediaPicker from "./MediaPicker.vue";
@@ -17,15 +17,15 @@ const open = ref(false);
class="flex size-28 shrink-0 items-center justify-center overflow-hidden rounded-lg border border-dashed border-gray-300 bg-gray-50"
>
<img v-if="url" :src="url" alt="" class="size-full object-contain" />
<PictureOutlined v-else class="text-2xl text-gray-300" />
<VuesaxIcon v-else name="gallery" :size="32" class="text-gray-300" />
</div>
<a-space direction="vertical">
<a-button @click="open = true">
<template #icon><PictureOutlined /></template>
<template #icon><VuesaxIcon name="gallery" :size="16" /></template>
{{ url ? "تغییر تصویر" : "انتخاب یا آپلود تصویر" }}
</a-button>
<a-button v-if="url && clearable" danger @click="emit('clear')">
<template #icon><DeleteOutlined /></template>
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
حذف تصویر
</a-button>
</a-space>
+31
View File
@@ -0,0 +1,31 @@
<script setup lang="ts">
import { ref, watch } from "vue";
/** Textarea where every non-empty line is one list item (for longer items than tags allow) */
const items = defineModel<string[]>({ required: true });
defineProps<{ placeholder?: string; rows?: number }>();
const parse = (text: string) =>
text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
// keep the raw text locally so blank lines and trailing newlines survive while typing
const text = ref(items.value.join("\n"));
watch(items, (v) => {
if (parse(text.value).join("\n") !== v.join("\n")) text.value = v.join("\n");
});
const onInput = () => {
items.value = parse(text.value);
};
</script>
<template>
<a-textarea
v-model:value="text"
:placeholder="placeholder"
:auto-size="{ minRows: rows ?? 3, maxRows: 14 }"
@change="onInput"
/>
</template>
+5 -5
View File
@@ -1,5 +1,5 @@
<script setup lang="ts" generic="T">
import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined } from "@ant-design/icons-vue";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
/** Editable list: add, remove and reorder items; each item is rendered by the default slot */
const items = defineModel<T[]>({ required: true });
@@ -41,14 +41,14 @@ const add = () => {
</span>
<a-space :size="4">
<a-button size="small" :disabled="i === 0" title="بالا" @click="move(i, -1)">
<template #icon><ArrowUpOutlined /></template>
<template #icon><VuesaxIcon name="arrow-up" :size="16" /></template>
</a-button>
<a-button size="small" :disabled="i === items.length - 1" title="پایین" @click="move(i, 1)">
<template #icon><ArrowDownOutlined /></template>
<template #icon><VuesaxIcon name="arrow-down" :size="16" /></template>
</a-button>
<a-popconfirm title="این مورد حذف شود؟" ok-text="حذف" cancel-text="انصراف" @confirm="remove(i)">
<a-button size="small" danger title="حذف">
<template #icon><DeleteOutlined /></template>
<template #icon><VuesaxIcon name="trash" :size="16" /></template>
</a-button>
</a-popconfirm>
</a-space>
@@ -56,7 +56,7 @@ const add = () => {
<slot :item="item" :index="i" />
</div>
<a-button type="dashed" block :disabled="items.length >= max" @click="add">
<template #icon><PlusOutlined /></template>
<template #icon><VuesaxIcon name="add" :size="16" /></template>
{{ addLabel }}
</a-button>
</div>
+2 -2
View File
@@ -1,9 +1,9 @@
<script setup lang="ts">
import { InboxOutlined } from "@ant-design/icons-vue";
import type { MediaDto, Paginated } from "@imen/shared";
import { message } from "ant-design-vue";
import { ref, watch } from "vue";
import { api, errorMessage } from "@/api/http";
import VuesaxIcon from "@/components/VuesaxIcon.vue";
/** Modal to upload a new image or pick one from the media library */
const open = defineModel<boolean>("open", { required: true });
@@ -102,7 +102,7 @@ async function upload(file: File) {
:show-upload-list="false"
:before-upload="upload"
>
<p class="ant-upload-drag-icon"><InboxOutlined /></p>
<p class="ant-upload-drag-icon"><VuesaxIcon name="document-upload" :size="40" /></p>
<p class="ant-upload-text">تصویر را اینجا رها کنید یا کلیک کنید</p>
<p class="ant-upload-hint">
JPG، PNG، WebP، AVIF یا GIF — حداکثر ۱۵ مگابایت. تصویر به‌صورت خودکار بهینه و به WebP تبدیل می‌شود.
+55 -7
View File
@@ -3,16 +3,64 @@ defineProps<{ title: string; subtitle?: string; back?: string }>();
</script>
<template>
<div class="mb-6 flex flex-wrap items-center justify-between gap-4">
<div class="flex items-center gap-3">
<RouterLink v-if="back" :to="back" class="text-gray-400 hover:text-gray-700" title="بازگشت">→</RouterLink>
<div>
<h1 class="text-xl font-bold text-gray-800">{{ title }}</h1>
<p v-if="subtitle" class="mt-1 text-sm text-gray-500">{{ subtitle }}</p>
<div class="page-header">
<div class="page-header__main">
<RouterLink v-if="back" :to="back" class="page-header__back" title="بازگشت">→</RouterLink>
<div class="min-w-0">
<h1 class="page-header__title">{{ title }}</h1>
<p v-if="subtitle" class="page-header__subtitle">{{ subtitle }}</p>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<div v-if="$slots.default" class="page-header__actions">
<slot />
</div>
</div>
</template>
<style scoped>
.page-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.page-header__main {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.page-header__back {
color: #94a3b8;
text-decoration: none;
transition: color 0.15s ease;
}
.page-header__back:hover {
color: #334155;
}
.page-header__title {
margin: 0;
font-size: 18px;
font-weight: 700;
line-height: 1.3;
color: #0f172a;
}
.page-header__subtitle {
margin: 2px 0 0;
font-size: 13px;
color: #94a3b8;
}
.page-header__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
</style>
+58
View File
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { computed } from "vue";
import icons from "@/icons/vuesax-linear.json";
const props = withDefaults(
defineProps<{
/** Short name (`add`) or full key (`vuesax-linear:add`) */
name: string;
size?: number | string;
}>(),
{ size: 18 },
);
const pack = icons as Record<string, string>;
const svgHtml = computed(() => {
const key = props.name.startsWith("vuesax-linear:")
? props.name
: `vuesax-linear:${props.name}`;
const raw = pack[key];
if (!raw) return "";
return raw
.replace(/\swidth="24"/, "")
.replace(/\sheight="24"/, "")
.replace(/stroke="#[A-Fa-f0-9]{3,8}"/g, 'stroke="currentColor"')
.replace(/fill="(?!none)#[A-Fa-f0-9]{3,8}"/g, 'fill="currentColor"');
});
const box = computed(() =>
typeof props.size === "number" ? `${props.size}px` : props.size,
);
</script>
<template>
<span
class="vx-icon"
:style="{ width: box, height: box }"
:aria-hidden="true"
v-html="svgHtml"
/>
</template>
<style scoped>
.vx-icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
line-height: 0;
color: inherit;
}
.vx-icon :deep(svg) {
width: 100%;
height: 100%;
display: block;
}
</style>
+5
View File
@@ -0,0 +1,5 @@
import { h, type VNode } from "vue";
import VuesaxIcon from "./VuesaxIcon.vue";
/** Render a Vuesax linear icon (for Ant menu `icon` factories, etc.) */
export const vx = (name: string, size = 18): VNode => h(VuesaxIcon, { name, size });