Adopt logistics visual system across webapp
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<footer class="bg-base-200 text-base-content/80 py-6">
|
||||
<div class="w-full px-4 sm:px-6 lg:px-8">
|
||||
<footer class="px-3 pb-8 pt-2 text-white md:px-4">
|
||||
<div class="mx-auto max-w-[1440px] rounded-[28px] border border-white/18 bg-[#10223b] px-6 py-6 shadow-[0_22px_54px_rgba(16,34,59,0.24)] [background-image:radial-gradient(circle_at_82%_18%,rgba(244,89,69,0.34),rgba(244,89,69,0)_34%),linear-gradient(130deg,#10223b_0%,#193450_100%)]">
|
||||
<div class="text-center">
|
||||
<p class="text-sm">© 2025 Optovia. {{ $t('footer.rights') }}</p>
|
||||
<p class="text-sm font-medium text-white/82">© 2025 Optovia. {{ $t('footer.rights') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,123 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { profileAvatarSeedFromValue, profileAvatarUrl } from '~/utils/profileAvatars'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
avatarSeed?: string | null
|
||||
seed?: string | null
|
||||
label?: string | null
|
||||
size?: number
|
||||
}>(), {
|
||||
avatarSeed: '',
|
||||
seed: '',
|
||||
label: '',
|
||||
size: 48,
|
||||
})
|
||||
|
||||
const normalizedSeed = computed(() => {
|
||||
const source = props.avatarSeed || props.seed || props.label || 'person'
|
||||
return profileAvatarSeedFromValue(source)
|
||||
})
|
||||
|
||||
const avatarSrc = computed(() => profileAvatarUrl(normalizedSeed.value))
|
||||
const avatarStyle = computed(() => ({
|
||||
width: `${props.size}px`,
|
||||
height: `${props.size}px`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center space-y-4">
|
||||
<!-- Avatar -->
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-24 h-24 rounded-full overflow-hidden border-4 border-base-300 shadow-lg"
|
||||
:class="{ 'animate-pulse bg-base-200': loading }"
|
||||
>
|
||||
<div
|
||||
v-if="!loading && avatarSvg"
|
||||
v-html="avatarSvg"
|
||||
class="w-full h-full"
|
||||
/>
|
||||
<div
|
||||
v-else-if="!loading"
|
||||
class="w-full h-full bg-gradient-to-br from-primary to-primary/70 flex items-center justify-center text-primary-content text-2xl font-bold"
|
||||
>
|
||||
{{ initials }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change avatar button -->
|
||||
<button
|
||||
@click="regenerateAvatar"
|
||||
:disabled="loading"
|
||||
class="absolute -bottom-1 -right-1 w-8 h-8 bg-primary hover:bg-primary/80 disabled:bg-base-300 text-primary-content rounded-full flex items-center justify-center shadow-lg transition-colors"
|
||||
:title="$t('profile.regenerate_avatar')"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- User name -->
|
||||
<div class="text-center">
|
||||
<p class="font-semibold text-base-content">{{ displayName }}</p>
|
||||
<p class="text-sm text-base-content/60" v-if="userId">ID: {{ userId }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="relative inline-flex shrink-0 items-center justify-center overflow-hidden rounded-[34%] border border-white/80 bg-white shadow-[0_12px_28px_rgba(47,36,24,0.16)]"
|
||||
:style="avatarStyle"
|
||||
:title="label || undefined"
|
||||
role="img"
|
||||
:aria-label="label ? `Аватар: ${label}` : 'Аватар'"
|
||||
>
|
||||
<img :src="avatarSrc" :alt="label || 'Аватар'" class="h-full w-full object-cover" loading="lazy">
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
userId: String,
|
||||
firstName: String,
|
||||
lastName: String,
|
||||
avatarId: String,
|
||||
editable: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['avatar-changed'])
|
||||
|
||||
const loading = ref(false)
|
||||
const avatarSvg = ref('')
|
||||
|
||||
// Computed properties
|
||||
const displayName = computed(() => {
|
||||
const first = props.firstName || ''
|
||||
const last = props.lastName || ''
|
||||
return `${first} ${last}`.trim() || 'User'
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
const first = props.firstName?.charAt(0) || ''
|
||||
const last = props.lastName?.charAt(0) || ''
|
||||
return (first + last).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
// Generate avatar via DiceBear API
|
||||
const generateAvatar = async (seed) => {
|
||||
if (!seed) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
|
||||
// Use DiceBear API to generate SVG avatar
|
||||
const response = await fetch(`https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(seed)}&backgroundColor=b6e3f4,c0aede,d1d4f9`)
|
||||
|
||||
if (response.ok) {
|
||||
avatarSvg.value = await response.text()
|
||||
} else {
|
||||
console.error('Failed to generate avatar:', response.status)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error generating avatar:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new random avatar ID
|
||||
const regenerateAvatar = async () => {
|
||||
if (!props.editable || loading.value) return
|
||||
|
||||
const newAvatarId = Math.random().toString(36).substring(2, 15)
|
||||
|
||||
// Update avatar locally first
|
||||
await generateAvatar(newAvatarId)
|
||||
|
||||
// Notify parent about avatar change
|
||||
emit('avatar-changed', newAvatarId)
|
||||
}
|
||||
|
||||
// Watch avatarId changes
|
||||
watch(() => props.avatarId, (newAvatarId) => {
|
||||
if (newAvatarId) {
|
||||
generateAvatar(newAvatarId)
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
// If no avatarId, generate deterministic one based on userId
|
||||
onMounted(async () => {
|
||||
if (!props.avatarId && props.userId) {
|
||||
// Build deterministic ID from userId
|
||||
const fallbackSeed = props.userId
|
||||
await generateAvatar(fallbackSeed)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
46
app/components/manager/ManagerListLoadMore.vue
Normal file
46
app/components/manager/ManagerListLoadMore.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
const props = withDefaults(defineProps<{
|
||||
shown: number
|
||||
total?: number | null
|
||||
canLoadMore?: boolean
|
||||
loading?: boolean
|
||||
pageSize?: number
|
||||
itemLabel?: string
|
||||
}>(), {
|
||||
total: null,
|
||||
canLoadMore: false,
|
||||
loading: false,
|
||||
pageSize: 12,
|
||||
itemLabel: 'элементов',
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadMore: []
|
||||
}>()
|
||||
|
||||
const summaryText = computed(() => {
|
||||
if (typeof props.total === 'number') {
|
||||
return `Показано ${props.shown} из ${props.total} ${props.itemLabel}`
|
||||
}
|
||||
|
||||
return `Загружено ${props.shown} ${props.itemLabel}`
|
||||
})
|
||||
|
||||
const buttonText = computed(() => props.loading ? 'Загружаем...' : `Загрузить ещё ${props.pageSize}`)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col items-center gap-3 rounded-[28px] bg-white/82 px-5 py-5 text-center text-sm text-[#6f6353] shadow-none">
|
||||
<p>{{ summaryText }}</p>
|
||||
<button
|
||||
v-if="canLoadMore"
|
||||
type="button"
|
||||
class="btn rounded-full border-0 bg-[#2f2418] px-6 text-white hover:bg-[#493824]"
|
||||
:disabled="loading"
|
||||
@click="emit('loadMore')"
|
||||
>
|
||||
<span v-if="loading" class="loading loading-spinner loading-sm" />
|
||||
{{ buttonText }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -369,7 +369,7 @@ const props = withDefaults(defineProps<{
|
||||
userAvatarSvg?: string
|
||||
userName?: string
|
||||
userInitials?: string
|
||||
theme?: 'cupcake' | 'night'
|
||||
theme?: 'silk' | 'night'
|
||||
userData?: {
|
||||
id?: string
|
||||
activeTeam?: { name?: string; teamType?: string }
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<nav v-if="items.length > 0" class="bg-base-100 shadow-sm">
|
||||
<div class="flex items-center gap-1 py-2 px-4 lg:px-6 overflow-x-auto">
|
||||
<nav v-if="items.length > 0" class="mx-auto mt-2 w-full max-w-[2200px] px-3 md:px-4">
|
||||
<div class="flex items-center gap-1 overflow-x-auto rounded-[24px] border border-[#e2d8ca] bg-[#efe6d8]/92 px-3 py-2 shadow-[0_14px_34px_rgba(47,36,24,0.08)] backdrop-blur">
|
||||
<NuxtLink
|
||||
v-for="item in items"
|
||||
:key="item.path"
|
||||
:to="localePath(item.path)"
|
||||
class="px-4 py-2 rounded-full text-sm font-medium transition-colors whitespace-nowrap text-base-content/70 hover:text-base-content hover:bg-base-200"
|
||||
:class="{ 'text-primary bg-primary/10': isActive(item.path) }"
|
||||
class="rounded-full px-4 py-2 text-sm font-bold whitespace-nowrap transition-colors text-[#5f4b33] hover:bg-[#f8f3ec]"
|
||||
:class="{ 'bg-[#2f2418] text-white shadow-[0_10px_24px_rgba(47,36,24,0.16)]': isActive(item.path) }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</NuxtLink>
|
||||
@@ -49,4 +49,3 @@ const isActive = (path: string) => {
|
||||
return route.path === localePath(path) || route.path.startsWith(localePath(path) + '/')
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
267
app/components/orders/OrdersCalendarPanel.vue
Normal file
267
app/components/orders/OrdersCalendarPanel.vue
Normal file
@@ -0,0 +1,267 @@
|
||||
<script setup lang="ts">
|
||||
type CalendarCheckpoint = {
|
||||
code: string
|
||||
name: string
|
||||
plannedDate?: string | null
|
||||
actualDate?: string | null
|
||||
completed: boolean
|
||||
current: boolean
|
||||
}
|
||||
|
||||
type CalendarOrder = {
|
||||
id: string
|
||||
status: string
|
||||
quotationId?: string | null
|
||||
totalAmount: number
|
||||
currency: string
|
||||
createdAt: string
|
||||
pickupDate?: string | null
|
||||
fromAddress: {
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
toAddress: {
|
||||
city: string
|
||||
country: string
|
||||
}
|
||||
currentCheckpoint?: {
|
||||
code: string
|
||||
name: string
|
||||
plannedDate?: string | null
|
||||
completed: boolean
|
||||
current: boolean
|
||||
} | null
|
||||
checkpoints: CalendarCheckpoint[]
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
orders: CalendarOrder[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
select: [orderId: string]
|
||||
}>()
|
||||
|
||||
const weekdayLabels = ['Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб', 'Вс']
|
||||
|
||||
function startOfMonthUtc(value: Date) {
|
||||
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth(), 1))
|
||||
}
|
||||
|
||||
function addMonthsUtc(value: Date, diff: number) {
|
||||
return new Date(Date.UTC(value.getUTCFullYear(), value.getUTCMonth() + diff, 1))
|
||||
}
|
||||
|
||||
function formatDateKey(value: Date) {
|
||||
return value.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function toDateOnlyDate(value?: string | null) {
|
||||
if (!value) return null
|
||||
const normalized = String(value).trim()
|
||||
if (!normalized) return null
|
||||
const date = new Date(`${normalized}T00:00:00.000Z`)
|
||||
if (Number.isNaN(date.getTime())) return null
|
||||
return date
|
||||
}
|
||||
|
||||
function orderAnchorCheckpoint(order: CalendarOrder) {
|
||||
const plannedCurrent = order.checkpoints.find(checkpoint => checkpoint.current && checkpoint.plannedDate)
|
||||
if (plannedCurrent) return plannedCurrent
|
||||
|
||||
const firstPending = order.checkpoints.find(checkpoint => !checkpoint.completed && checkpoint.plannedDate)
|
||||
if (firstPending) return firstPending
|
||||
|
||||
const lastPlanned = [...order.checkpoints].reverse().find(checkpoint => checkpoint.plannedDate)
|
||||
if (lastPlanned) return lastPlanned
|
||||
|
||||
return order.currentCheckpoint || null
|
||||
}
|
||||
|
||||
function orderCalendarDate(order: CalendarOrder) {
|
||||
const checkpointDate = orderAnchorCheckpoint(order)?.plannedDate
|
||||
if (checkpointDate) return checkpointDate
|
||||
if (order.pickupDate) return order.pickupDate
|
||||
return order.createdAt.slice(0, 10)
|
||||
}
|
||||
|
||||
function orderCalendarLabel(order: CalendarOrder) {
|
||||
const checkpoint = orderAnchorCheckpoint(order)
|
||||
if (checkpoint?.name) return checkpoint.name
|
||||
return 'Дата уточняется'
|
||||
}
|
||||
|
||||
function orderPersonLabel(order: CalendarOrder) {
|
||||
if (order.quotationId) {
|
||||
return `Клиент ${order.quotationId.slice(-6).toUpperCase()}`
|
||||
}
|
||||
|
||||
return `Клиент ${order.id.slice(-6).toUpperCase()}`
|
||||
}
|
||||
|
||||
function orderAvatarSeed(order: CalendarOrder) {
|
||||
return order.quotationId || order.id
|
||||
}
|
||||
|
||||
const initialMonth = computed(() => {
|
||||
const firstOrderDate = props.orders
|
||||
.map(order => toDateOnlyDate(orderCalendarDate(order)))
|
||||
.find(Boolean)
|
||||
|
||||
return startOfMonthUtc(firstOrderDate || new Date())
|
||||
})
|
||||
|
||||
const visibleMonth = ref(startOfMonthUtc(initialMonth.value))
|
||||
|
||||
watch(initialMonth, (nextValue) => {
|
||||
visibleMonth.value = startOfMonthUtc(nextValue)
|
||||
})
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
return new Intl.DateTimeFormat('ru-RU', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC',
|
||||
}).format(visibleMonth.value)
|
||||
})
|
||||
|
||||
const calendarOrdersByDay = computed(() => {
|
||||
return props.orders.reduce<Record<string, CalendarOrder[]>>((acc, order) => {
|
||||
const dateKey = orderCalendarDate(order)
|
||||
if (!dateKey) return acc
|
||||
if (!acc[dateKey]) acc[dateKey] = []
|
||||
acc[dateKey].push(order)
|
||||
return acc
|
||||
}, {})
|
||||
})
|
||||
|
||||
const monthCells = computed(() => {
|
||||
const firstDay = visibleMonth.value
|
||||
const firstWeekday = (firstDay.getUTCDay() + 6) % 7
|
||||
const gridStart = new Date(firstDay.getTime())
|
||||
gridStart.setUTCDate(gridStart.getUTCDate() - firstWeekday)
|
||||
|
||||
return Array.from({ length: 42 }, (_, index) => {
|
||||
const date = new Date(gridStart.getTime())
|
||||
date.setUTCDate(gridStart.getUTCDate() + index)
|
||||
const dateKey = formatDateKey(date)
|
||||
return {
|
||||
key: dateKey,
|
||||
date,
|
||||
dateKey,
|
||||
inCurrentMonth: date.getUTCMonth() === visibleMonth.value.getUTCMonth(),
|
||||
isToday: dateKey === formatDateKey(new Date()),
|
||||
orders: calendarOrdersByDay.value[dateKey] || [],
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
function previousMonth() {
|
||||
visibleMonth.value = addMonthsUtc(visibleMonth.value, -1)
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
visibleMonth.value = addMonthsUtc(visibleMonth.value, 1)
|
||||
}
|
||||
|
||||
function openOrder(orderId: string) {
|
||||
emit('select', orderId)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-[28px] bg-white p-4 md:p-5">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-xs font-bold uppercase tracking-[0.14em] text-[#8c7b67]">Orders calendar</p>
|
||||
<p class="mt-1 text-lg font-black capitalize text-[#2f2418]">{{ monthLabel }}</p>
|
||||
</div>
|
||||
|
||||
<div class="inline-flex items-center rounded-full bg-[#f6f1ea] p-1">
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full text-[#5f4b33] transition hover:bg-white"
|
||||
aria-label="Previous month"
|
||||
@click="previousMonth"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full text-[#5f4b33] transition hover:bg-white"
|
||||
aria-label="Next month"
|
||||
@click="nextMonth"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" class="h-4 w-4" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid grid-cols-7 gap-2">
|
||||
<div
|
||||
v-for="weekday in weekdayLabels"
|
||||
:key="weekday"
|
||||
class="px-1 text-center text-[11px] font-bold uppercase tracking-[0.12em] text-[#8c7b67]"
|
||||
>
|
||||
{{ weekday }}
|
||||
</div>
|
||||
|
||||
<article
|
||||
v-for="cell in monthCells"
|
||||
:key="cell.key"
|
||||
class="flex min-h-[132px] flex-col rounded-[22px] border border-[#e6ddd1] bg-[#fbf8f4] p-2.5"
|
||||
:class="[
|
||||
cell.inCurrentMonth ? 'opacity-100' : 'opacity-45',
|
||||
cell.isToday ? 'ring-2 ring-[#8bc7f2]/70' : '',
|
||||
]"
|
||||
>
|
||||
<div class="mb-2 flex items-center justify-between gap-2">
|
||||
<span class="text-sm font-black text-[#2f2418]">{{ cell.date.getUTCDate() }}</span>
|
||||
<span v-if="cell.orders.length" class="rounded-full bg-white px-2 py-0.5 text-[10px] font-bold uppercase tracking-[0.1em] text-[#5f4b33]">
|
||||
{{ cell.orders.length }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-1.5">
|
||||
<button
|
||||
v-for="order in cell.orders.slice(0, 3)"
|
||||
:key="order.id"
|
||||
type="button"
|
||||
class="rounded-[16px] bg-white px-2.5 py-2 text-left transition hover:shadow-[0_12px_30px_rgba(38,29,18,0.14)]"
|
||||
@click="openOrder(order.id)"
|
||||
>
|
||||
<div class="flex items-start gap-2">
|
||||
<UserAvatar
|
||||
:seed="orderAvatarSeed(order)"
|
||||
:label="orderPersonLabel(order)"
|
||||
:size="26"
|
||||
/>
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-[11px] font-black text-[#2f2418]">
|
||||
{{ orderPersonLabel(order) }}
|
||||
</p>
|
||||
<p class="truncate text-xs font-black text-[#2f2418]">
|
||||
{{ order.fromAddress.city }} • {{ order.toAddress.city }}
|
||||
</p>
|
||||
<p class="mt-0.5 truncate text-[11px] text-[#7c6d5d]">
|
||||
{{ orderCalendarLabel(order) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="cell.orders.length > 3"
|
||||
class="rounded-[16px] bg-white/70 px-2.5 py-2 text-[11px] font-semibold text-[#7c6d5d]"
|
||||
>
|
||||
Ещё {{ cell.orders.length - 3 }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -17,22 +17,22 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const variantMap: Record<string, string> = {
|
||||
default: 'badge-neutral',
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
error: 'badge-error',
|
||||
muted: 'badge-ghost',
|
||||
primary: 'badge-primary',
|
||||
default: 'bg-[#f6f1ea] text-[#5f4b33]',
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
warning: 'bg-amber-100 text-amber-700',
|
||||
error: 'bg-rose-100 text-rose-700',
|
||||
muted: 'bg-[#efe7da] text-[#8a7761]',
|
||||
primary: 'bg-[#2f2418] text-white',
|
||||
}
|
||||
|
||||
const sizeMap: Record<string, string> = {
|
||||
xs: 'badge-xs',
|
||||
sm: 'badge-sm',
|
||||
md: 'badge-md',
|
||||
xs: 'px-2 py-1 text-[10px]',
|
||||
sm: 'px-3 py-1 text-xs',
|
||||
md: 'px-3.5 py-1.5 text-sm',
|
||||
}
|
||||
|
||||
const badgeClass = computed(() => {
|
||||
const base = 'badge'
|
||||
const base = 'inline-flex items-center rounded-full font-semibold'
|
||||
const variantClass = variantMap[props.variant] || variantMap.default
|
||||
const sizeClass = sizeMap[props.size] || sizeMap.sm
|
||||
return [base, variantClass, sizeClass].join(' ')
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<component
|
||||
:is="componentTag"
|
||||
:type="componentType"
|
||||
:class="['btn', variantClass, fullWidth ? 'w-full' : '']"
|
||||
:class="[baseClass, variantClass, fullWidth ? 'w-full' : '']"
|
||||
v-bind="$attrs"
|
||||
>
|
||||
<slot />
|
||||
@@ -36,10 +36,11 @@ const componentTag = computed(() => {
|
||||
return props.as || 'button'
|
||||
})
|
||||
const componentType = computed(() => (props.as === 'button' ? props.type : undefined))
|
||||
const baseClass = 'inline-flex items-center justify-center gap-2 rounded-full border-0 px-5 py-3 text-sm font-bold transition duration-200'
|
||||
|
||||
const variantClass = computed(() => {
|
||||
if (props.variant === 'outline') return 'btn-outline btn-primary'
|
||||
if (props.variant === 'ghost') return 'btn-ghost'
|
||||
return 'btn-primary'
|
||||
if (props.variant === 'outline') return 'bg-transparent text-[#2f2418] ring-1 ring-[#cbbca6] hover:bg-[#f6f1ea]'
|
||||
if (props.variant === 'ghost') return 'bg-[#f6f1ea] text-[#5f4b33] hover:bg-[#ece2d3]'
|
||||
return 'bg-[#2f2418] text-white shadow-[0_12px_28px_rgba(47,36,24,0.16)] hover:bg-[#493824]'
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -27,18 +27,18 @@ const paddingMap: Record<string, string> = {
|
||||
}
|
||||
|
||||
const toneMap: Record<string, string> = {
|
||||
default: 'bg-base-100',
|
||||
muted: 'bg-base-200',
|
||||
primary: 'bg-primary/10',
|
||||
default: 'bg-white',
|
||||
muted: 'bg-[#fbf8f4]',
|
||||
primary: 'bg-[#f6f1ea]',
|
||||
}
|
||||
|
||||
const cardClass = computed(() => {
|
||||
const paddingClass = paddingMap[props.padding] || paddingMap.medium
|
||||
const toneClass = toneMap[props.tone] || toneMap.default
|
||||
const interactiveClass = props.interactive
|
||||
? 'cursor-pointer hover:shadow-lg transition-shadow duration-200'
|
||||
? 'cursor-pointer transition-[transform,box-shadow] duration-200 hover:-translate-y-0.5 hover:shadow-[0_18px_34px_rgba(62,47,26,0.12)]'
|
||||
: ''
|
||||
const baseClass = 'card'
|
||||
const baseClass = 'rounded-[28px] border border-[#eadfce] text-[#2f2418] shadow-none'
|
||||
return [baseClass, paddingClass, toneClass, interactiveClass].filter(Boolean).join(' ')
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<label v-if="label" class="w-full space-y-1">
|
||||
<span class="text-base font-semibold text-base-content">{{ label }}</span>
|
||||
<label v-if="label" class="w-full space-y-2">
|
||||
<span class="text-sm font-bold uppercase tracking-[0.12em] text-[#8a7761]">{{ label }}</span>
|
||||
<input
|
||||
v-bind="$attrs"
|
||||
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
||||
class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
@@ -11,7 +11,7 @@
|
||||
<input
|
||||
v-else
|
||||
v-bind="$attrs"
|
||||
class="input input-bordered w-full bg-base-100 text-base-content placeholder-base-content/60"
|
||||
class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]"
|
||||
:value="modelValue"
|
||||
@input="onInput"
|
||||
/>
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<template>
|
||||
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<h1 class="text-2xl lg:text-3xl font-bold text-base-content">{{ title }}</h1>
|
||||
<p v-if="description" class="text-base-content/70">{{ description }}</p>
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div class="space-y-2">
|
||||
<p class="text-xs font-bold uppercase tracking-[0.16em] text-[#8c7b67]">Workspace</p>
|
||||
<h1 class="text-2xl font-black text-[#2f2418] lg:text-3xl">{{ title }}</h1>
|
||||
<p v-if="description" class="max-w-[720px] text-sm leading-6 text-[#6f6353]">{{ description }}</p>
|
||||
</div>
|
||||
<div v-if="$slots.actions || actions?.length" class="flex items-center gap-2 flex-shrink-0">
|
||||
<slot name="actions">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<component
|
||||
:is="to ? NuxtLink : 'button'"
|
||||
:to="to"
|
||||
class="btn btn-sm btn-ghost gap-2"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-[#f6f1ea] px-4 py-2 text-sm font-bold text-[#5f4b33] transition hover:bg-[#ece2d3]"
|
||||
@click="!to && $emit('click')"
|
||||
>
|
||||
<Icon v-if="icon" :name="icon" size="16" />
|
||||
|
||||
@@ -21,20 +21,20 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const variantMap: Record<string, string> = {
|
||||
neutral: 'badge-neutral',
|
||||
primary: 'badge-primary',
|
||||
outline: 'badge-outline',
|
||||
inverse: 'badge-ghost bg-white/10 text-white border border-white/40',
|
||||
neutral: 'bg-[#f6f1ea] text-[#5f4b33]',
|
||||
primary: 'bg-[#2f2418] text-white',
|
||||
outline: 'bg-transparent text-[#2f2418] ring-1 ring-[#cbbca6]',
|
||||
inverse: 'bg-white/10 text-white border border-white/40',
|
||||
}
|
||||
|
||||
const toneMap: Record<string, string> = {
|
||||
default: '',
|
||||
success: 'badge-success',
|
||||
warning: 'badge-warning',
|
||||
success: 'bg-emerald-100 text-emerald-700',
|
||||
warning: 'bg-amber-100 text-amber-700',
|
||||
}
|
||||
|
||||
const pillClass = computed(() => {
|
||||
const base = ['badge', props.size === 'sm' ? 'badge-sm' : 'badge-md']
|
||||
const base = ['inline-flex items-center rounded-full font-semibold', props.size === 'sm' ? 'px-3 py-1 text-xs' : 'px-3.5 py-1.5 text-sm']
|
||||
const variantClass = variantMap[props.variant] || variantMap.neutral
|
||||
const toneClass = toneMap[props.tone] || ''
|
||||
return [base, variantClass, toneClass].flat().filter(Boolean).join(' ')
|
||||
|
||||
@@ -17,8 +17,8 @@ const props = defineProps({
|
||||
})
|
||||
|
||||
const variantMap: Record<string, string> = {
|
||||
default: 'bg-base-200 text-base-content',
|
||||
hero: 'bg-primary text-primary-content rounded-box overflow-hidden px-6',
|
||||
default: 'rounded-[28px] bg-white px-6 text-[#2f2418] shadow-none',
|
||||
hero: 'overflow-hidden rounded-[34px] bg-[#10223b] px-6 text-white shadow-[0_22px_54px_rgba(16,34,59,0.24)]',
|
||||
plain: '',
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<select v-bind="$attrs" class="select select-bordered w-full">
|
||||
<select v-bind="$attrs" class="h-12 w-full rounded-full border-0 bg-[#f6f1ea] px-5 text-[#2f2418] shadow-none outline-none">
|
||||
<slot />
|
||||
</select>
|
||||
</template>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<label v-if="label" class="w-full space-y-1">
|
||||
<span class="text-base font-semibold text-base-content">{{ label }}</span>
|
||||
<label v-if="label" class="w-full space-y-2">
|
||||
<span class="text-sm font-bold uppercase tracking-[0.12em] text-[#8a7761]">{{ label }}</span>
|
||||
<textarea
|
||||
v-bind="$attrs"
|
||||
:class="fieldClass"
|
||||
@@ -39,6 +39,6 @@ const onInput = (event: Event) => {
|
||||
}
|
||||
|
||||
const fieldClass = computed(() =>
|
||||
['textarea textarea-bordered w-full min-h-[120px]', props.mono ? 'font-mono' : ''].join(' ')
|
||||
['w-full min-h-[120px] rounded-[24px] border-0 bg-[#f6f1ea] px-5 py-4 text-[#2f2418] shadow-none outline-none placeholder:text-[#9b8d79]', props.mono ? 'font-mono' : ''].join(' ')
|
||||
)
|
||||
</script>
|
||||
|
||||
Reference in New Issue
Block a user