Performing logins, mobile cards, and other tasks
This commit is contained in:
@@ -126,7 +126,12 @@ const tipSubmitting = ref(false);
|
||||
const tipAmountToman = computed(() => parsePriceInput(tipAmountInput.value));
|
||||
|
||||
async function loadPending() {
|
||||
if (!auth.isLoggedIn || auth.isBusinessOwner) return;
|
||||
if (!auth.isLoggedIn) return;
|
||||
if (auth.token && !auth.user) {
|
||||
await auth.loadUser();
|
||||
}
|
||||
// Business owners get 403 from this customer-only endpoint.
|
||||
if (auth.isBusinessOwner || !auth.user) return;
|
||||
try {
|
||||
pending.value = await fetchPendingReviews();
|
||||
if (!activeReview.value && pending.value.length) {
|
||||
@@ -241,9 +246,14 @@ async function submitTip() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => auth.isLoggedIn,
|
||||
(loggedIn) => {
|
||||
() => [auth.isLoggedIn, auth.user?.role] as const,
|
||||
([loggedIn]) => {
|
||||
if (loggedIn) void loadPending();
|
||||
else {
|
||||
pending.value = [];
|
||||
activeReview.value = null;
|
||||
reviewOpen.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -4,33 +4,33 @@
|
||||
class="flex items-center gap-3 rounded-xl border border-border bg-surface-2 p-2.5 transition hover:border-brand-500/40 hover:shadow-sm"
|
||||
>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0">
|
||||
<h2 class="truncate text-sm font-semibold text-text">{{ business.title }}</h2>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 items-center gap-2">
|
||||
<h2 class="min-w-0 truncate text-sm font-semibold text-text">{{ business.title }}</h2>
|
||||
<BusinessRatingBadge
|
||||
class="mt-1"
|
||||
class="shrink-0"
|
||||
:average-rating="business.averageRating"
|
||||
:review-count="business.reviewCount"
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
v-if="business.category"
|
||||
class="shrink-0 rounded-lg bg-brand-500/10 px-1.5 py-0.5 text-[10px] text-brand-700 dark:text-brand-500"
|
||||
<span
|
||||
v-if="business.category"
|
||||
class="shrink-0 rounded-lg bg-brand-500/10 px-1.5 py-0.5 text-[10px] text-brand-700 dark:text-brand-500"
|
||||
>
|
||||
{{ business.category.title }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="summary" class="mt-0.5 truncate text-xs text-muted">
|
||||
{{ summary }}
|
||||
</p>
|
||||
<p
|
||||
v-if="business.address"
|
||||
class="mt-0.5 flex items-center gap-1 truncate text-xs text-muted"
|
||||
>
|
||||
{{ business.category.title }}
|
||||
</span>
|
||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||
<span class="truncate">{{ business.address }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="business.description" class="mt-0.5 truncate text-xs text-muted">
|
||||
{{ business.description }}
|
||||
</p>
|
||||
<p
|
||||
v-if="business.address"
|
||||
class="mt-0.5 flex items-center gap-1 truncate text-xs text-muted"
|
||||
>
|
||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||
<span class="truncate">{{ business.address }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-surface-3 sm:h-16 sm:w-16"
|
||||
@@ -64,4 +64,6 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const cover = computed(() => businessCoverUrl(props.business.images));
|
||||
|
||||
const summary = computed(() => props.business.subtitle?.trim() || '');
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,32 @@
|
||||
<template>
|
||||
<div class="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
|
||||
<!-- Mobile / tablet: select + «همه» -->
|
||||
<div class="flex items-stretch gap-2 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-xl px-4 text-sm font-medium transition"
|
||||
:class="
|
||||
!selectedId
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'border border-border bg-surface-3 text-muted hover:text-text'
|
||||
"
|
||||
@click="emit('select', null)"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
<select
|
||||
class="min-w-0 flex-1 rounded-xl border border-border bg-surface-2 px-3 py-2.5 text-sm text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
:value="selectedId ?? ''"
|
||||
@change="onSelectChange"
|
||||
>
|
||||
<option value="" disabled>انتخاب دسته</option>
|
||||
<option v-for="cat in categories" :key="cat.id" :value="cat.id">
|
||||
{{ cat.title }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Desktop: chip row -->
|
||||
<div class="-mx-1 hidden gap-2 overflow-x-auto px-1 pb-1 lg:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 rounded-full px-4 py-2 text-sm font-medium transition"
|
||||
@@ -8,7 +35,7 @@
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-surface-3 text-muted hover:text-text'
|
||||
"
|
||||
@click="$emit('select', null)"
|
||||
@click="emit('select', null)"
|
||||
>
|
||||
همه
|
||||
</button>
|
||||
@@ -22,7 +49,7 @@
|
||||
? 'bg-brand-600 text-white'
|
||||
: 'bg-surface-3 text-muted hover:text-text'
|
||||
"
|
||||
@click="$emit('select', cat.id)"
|
||||
@click="emit('select', cat.id)"
|
||||
>
|
||||
{{ cat.title }}
|
||||
</button>
|
||||
@@ -37,7 +64,12 @@ defineProps<{
|
||||
selectedId: string | null;
|
||||
}>();
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
select: [categoryId: string | null];
|
||||
}>();
|
||||
|
||||
function onSelectChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value;
|
||||
emit('select', value || null);
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,48 +1,55 @@
|
||||
<template>
|
||||
<RouterLink
|
||||
:to="{ name: 'app-business-detail', params: { id: business.id } }"
|
||||
class="group flex h-full flex-col rounded-xl border border-slate-200 bg-white p-4 text-center shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-border dark:bg-surface"
|
||||
class="group flex h-full flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm transition hover:-translate-y-0.5 hover:border-brand-500 hover:shadow-md dark:border-border dark:bg-surface"
|
||||
:class="featured ? 'border-brand-500 ring-1 ring-brand-500/20' : ''"
|
||||
>
|
||||
<div
|
||||
class="mx-auto mb-3 flex h-12 w-12 items-center justify-center overflow-hidden rounded-full bg-brand-500/10 text-brand-600 transition group-hover:bg-brand-500/15 dark:text-brand-500"
|
||||
>
|
||||
<div class="relative aspect-[16/10] w-full overflow-hidden bg-slate-100 dark:bg-surface-3">
|
||||
<img
|
||||
v-if="cover"
|
||||
:src="cover"
|
||||
:alt="business.title"
|
||||
class="h-full w-full object-cover"
|
||||
class="h-full w-full object-cover transition duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
<AppIcon v-else name="store" size="md" />
|
||||
<div v-else class="flex h-full w-full items-center justify-center text-muted/40">
|
||||
<AppIcon name="store" size="lg" />
|
||||
</div>
|
||||
<span
|
||||
v-if="business.category"
|
||||
class="absolute right-2 top-2 rounded-md bg-white/95 px-2 py-0.5 text-[11px] font-medium text-brand-600 shadow-sm backdrop-blur-sm dark:bg-surface/95 dark:text-brand-500"
|
||||
>
|
||||
{{ business.category.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<h3 class="text-sm font-bold text-slate-800 dark:text-text">{{ business.title }}</h3>
|
||||
<div class="flex flex-1 flex-col space-y-1.5 p-3.5 text-start">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<h3 class="min-w-0 flex-1 text-sm font-bold text-slate-800 dark:text-text">
|
||||
{{ business.title }}
|
||||
</h3>
|
||||
<BusinessRatingBadge
|
||||
class="shrink-0"
|
||||
:average-rating="business.averageRating"
|
||||
:review-count="business.reviewCount"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BusinessRatingBadge
|
||||
class="mx-auto mt-1.5"
|
||||
:average-rating="business.averageRating"
|
||||
:review-count="business.reviewCount"
|
||||
/>
|
||||
<p
|
||||
v-if="summary"
|
||||
class="line-clamp-2 text-xs leading-5 text-slate-600 dark:text-muted"
|
||||
>
|
||||
{{ summary }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="business.description"
|
||||
class="mt-1.5 line-clamp-2 text-xs leading-6 text-slate-600 dark:text-muted"
|
||||
>
|
||||
{{ business.description }}
|
||||
</p>
|
||||
|
||||
<p v-if="business.category" class="mt-2 text-[11px] font-medium text-brand-600 dark:text-brand-500">
|
||||
{{ business.category.title }}
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="business.address"
|
||||
class="mt-1.5 flex items-center justify-center gap-1 text-[11px] text-slate-500 dark:text-muted"
|
||||
>
|
||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||
<span class="line-clamp-1">{{ business.address }}</span>
|
||||
</p>
|
||||
<p
|
||||
v-if="business.address"
|
||||
class="mt-auto flex items-center gap-1 pt-1 text-[11px] text-slate-500 dark:text-muted"
|
||||
>
|
||||
<AppIcon name="map-pin" size="sm" class="shrink-0" />
|
||||
<span class="line-clamp-1">{{ business.address }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</template>
|
||||
|
||||
@@ -59,4 +66,5 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const cover = computed(() => businessCoverUrl(props.business.images));
|
||||
const summary = computed(() => props.business.subtitle?.trim() || '');
|
||||
</script>
|
||||
|
||||
@@ -13,20 +13,9 @@
|
||||
>
|
||||
کسبوکاری یافت نشد.
|
||||
</div>
|
||||
<ul
|
||||
v-else
|
||||
:class="
|
||||
variant === 'featured'
|
||||
? 'grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4'
|
||||
: 'grid grid-cols-1 gap-2'
|
||||
"
|
||||
>
|
||||
<li v-for="(business, index) in businesses" :key="business.id">
|
||||
<HomeBusinessCard
|
||||
v-if="variant === 'featured'"
|
||||
:business="business"
|
||||
:featured="index === 1"
|
||||
/>
|
||||
<ul v-else :class="gridClass">
|
||||
<li v-for="business in businesses" :key="business.id">
|
||||
<HomeBusinessCard v-if="useFeaturedCards" :business="business" />
|
||||
<BusinessCard v-else :business="business" />
|
||||
</li>
|
||||
</ul>
|
||||
@@ -34,6 +23,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useMediaQuery } from '@vueuse/core';
|
||||
import { computed } from 'vue';
|
||||
import type { Business } from '@/api/types';
|
||||
import BusinessCard from '@/components/business/BusinessCard.vue';
|
||||
import HomeBusinessCard from '@/components/home/HomeBusinessCard.vue';
|
||||
@@ -49,4 +40,25 @@ const props = withDefaults(
|
||||
}>(),
|
||||
{ variant: 'compact', class: '' },
|
||||
);
|
||||
|
||||
/** Cover-style cards from 425px up. */
|
||||
const isWideMobile = useMediaQuery('(min-width: 425px)');
|
||||
|
||||
const useFeaturedCards = computed(
|
||||
() => props.variant === 'featured' || isWideMobile.value,
|
||||
);
|
||||
|
||||
/**
|
||||
* <425: 1 col (list)
|
||||
* ≥425: 2 cols
|
||||
* ≥768: 3 cols
|
||||
* ≥1024: 4 cols
|
||||
* ≥1440: 5 cols
|
||||
*/
|
||||
const gridClass = computed(() => {
|
||||
if (!useFeaturedCards.value) {
|
||||
return 'grid grid-cols-1 gap-2';
|
||||
}
|
||||
return 'grid grid-cols-2 gap-3 sm:gap-4 md:grid-cols-3 lg:grid-cols-4 min-[1440px]:grid-cols-5';
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -117,10 +117,20 @@ watch(selectedCategoryId, () => {
|
||||
void loadBusinesses();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [auth.isLoggedIn, auth.user?.id, auth.user?.role] as const,
|
||||
async () => {
|
||||
if (auth.isLoggedIn) {
|
||||
await auth.loadUser();
|
||||
await ensureLoaded(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
if (auth.isLoggedIn) {
|
||||
await auth.loadUser();
|
||||
await ensureLoaded();
|
||||
await ensureLoaded(true);
|
||||
}
|
||||
await loadCategories();
|
||||
await loadBusinesses();
|
||||
|
||||
@@ -88,7 +88,7 @@ import { storeToRefs } from 'pinia';
|
||||
import BusinessImageGallery from '@/components/business/BusinessImageGallery.vue';
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import { useTenantStore } from '@/stores/tenant';
|
||||
import type { AppIconName } from '@/types/icons';
|
||||
import { resolveBusinessHighlights } from '@/constants/business-highlights';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -111,9 +111,7 @@ const heroSubtitle = computed(() => landingBusiness.value?.subtitle?.trim() ?? '
|
||||
|
||||
const heroDescription = computed(() => landingBusiness.value?.description?.trim() ?? '');
|
||||
|
||||
const highlights: { label: string; icon: AppIconName }[] = [
|
||||
{ label: 'رضایت مشتریان', icon: 'shield' },
|
||||
{ label: 'تجهیزات مدرن', icon: 'settings' },
|
||||
{ label: 'مواد شوینده با کیفیت', icon: 'droplet' },
|
||||
];
|
||||
const highlights = computed(() =>
|
||||
resolveBusinessHighlights(landingBusiness.value?.heroHighlightKeys),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
<template>
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-text">{{ label }}</label>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
حداکثر ۳ مورد برای نمایش در صفحه اصلی کسبوکار. اگر چیزی انتخاب نکنید، موارد پیشفرض نمایش داده میشود.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
<label
|
||||
v-for="item in catalog"
|
||||
:key="item.key"
|
||||
class="flex cursor-pointer items-center gap-2.5 rounded-xl border px-3 py-2.5 text-sm transition"
|
||||
:class="
|
||||
isSelected(item.key)
|
||||
? 'border-brand-500/50 bg-brand-500/10 text-text'
|
||||
: 'border-border bg-surface-2 text-muted hover:border-brand-500/30'
|
||||
"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="rounded border-border"
|
||||
:checked="isSelected(item.key)"
|
||||
:disabled="!isSelected(item.key) && modelValue.length >= 3"
|
||||
@change="toggle(item.key)"
|
||||
/>
|
||||
<AppIcon :name="item.icon" size="sm" class="shrink-0 text-brand-600 dark:text-brand-500" />
|
||||
<span class="min-w-0 flex-1 font-medium text-text">{{ item.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-muted">{{ modelValue.length }} از ۳ انتخاب شده</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import AppIcon from '@/components/ui/AppIcon.vue';
|
||||
import {
|
||||
BUSINESS_HIGHLIGHT_CATALOG,
|
||||
type BusinessHighlightKey,
|
||||
} from '@/constants/business-highlights';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: string[];
|
||||
label?: string;
|
||||
}>(),
|
||||
{ label: 'نکات برجسته لندینگ' },
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
}>();
|
||||
|
||||
const catalog = BUSINESS_HIGHLIGHT_CATALOG;
|
||||
|
||||
function isSelected(key: BusinessHighlightKey) {
|
||||
return props.modelValue.includes(key);
|
||||
}
|
||||
|
||||
function toggle(key: BusinessHighlightKey) {
|
||||
if (isSelected(key)) {
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((item) => item !== key),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (props.modelValue.length >= 3) return;
|
||||
emit('update:modelValue', [...props.modelValue, key]);
|
||||
}
|
||||
</script>
|
||||
@@ -21,6 +21,7 @@ import { computed } from 'vue';
|
||||
import {
|
||||
Gift,
|
||||
Info,
|
||||
LogOut,
|
||||
Armchair,
|
||||
ArrowRight,
|
||||
BadgePercent,
|
||||
@@ -111,6 +112,7 @@ const iconMap: Record<AppIconName, Component> = {
|
||||
zap: Zap,
|
||||
gift: Gift,
|
||||
info: Info,
|
||||
'log-out': LogOut,
|
||||
};
|
||||
|
||||
const props = withDefaults(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
<select
|
||||
:id="id"
|
||||
:value="modelValue"
|
||||
:required="required"
|
||||
class="w-full rounded-xl border border-border bg-surface-2 px-4 py-3 text-text outline-none focus:border-brand-500 focus:ring-2 focus:ring-brand-500/30"
|
||||
:class="ltr ? 'text-left' : ''"
|
||||
@change="onChange"
|
||||
@@ -26,6 +27,7 @@ defineProps<{
|
||||
options: { value: string; label: string }[];
|
||||
placeholder?: string;
|
||||
ltr?: boolean;
|
||||
required?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>();
|
||||
|
||||
Reference in New Issue
Block a user