petstore-frontend/src/pages/appointment/CustAppointmentCreate.vue

855 lines
27 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page-shell cust-appt-create">
<view class="create-nav nav-gradient" :style="navSafeStyle">
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
<text class="nav-title">创建预约</text>
<view style="width:32px"></view>
</view>
<view class="c-hero">
<text class="c-hero-title">预约宠物服务</text>
<text class="c-hero-sub">在线预约专业服务</text>
</view>
<view class="c-form-card">
<view v-if="useCompactStoreRow" class="c-field">
<text class="app-form-label">预约门店</text>
<view class="c-store-summary-row">
<text class="c-store-summary-name">{{ currentStoreDisplayName }}</text>
<text
v-if="orderedStores.length > 1 && storeLockedCompact"
class="c-store-change-link"
@click="expandStorePicker"
>更换门店</text>
</view>
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
</view>
<view v-else class="c-field">
<text class="app-form-label">预约门店</text>
<picker
mode="selector"
:range="orderedStores"
range-key="pickerLabel"
:value="selectedStoreIndex"
:disabled="!orderedStores.length"
@change="onStorePickerChange"
>
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !orderedStores.length }">{{ storePickerDisplay }}</text>
<text class="app-form-arrow"></text>
</view>
</picker>
<text
v-if="showCollapseStoreLink"
class="c-store-collapse"
@click="onCollapseStorePicker"
>收起</text>
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
</view>
<view class="c-field">
<text class="app-form-label">宠物名字</text>
<view class="pet-name-row">
<input
v-model="newAppt.petName"
class="app-form-input pet-name-input"
placeholder="请输入宠物名字"
@input="onPetNameInput"
/>
<button
v-if="myPets.length > 1"
class="app-btn-sm app-btn-soft pet-switch-btn"
@click="openPetSwitcher"
>切换宠物</button>
</view>
</view>
<view class="c-field">
<text class="app-form-label">宠物类型</text>
<picker mode="selector" :range="petTypes" range-key="label" @change="onPetTypeChange">
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text>
<text class="app-form-arrow"></text>
</view>
</picker>
</view>
<view class="c-field">
<text class="app-form-label">服务类型</text>
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="app-form-arrow"></text>
</view>
</picker>
</view>
<view class="c-field">
<text class="app-form-label">预约时段</text>
<view class="c-time-row">
<picker mode="date" :value="selectedDateStr" @change="onAppointmentDateChange" class="c-time-half">
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !selectedDateStr }">{{ selectedDateStr || '日期' }}</text>
</view>
</picker>
<picker
mode="selector"
:range="availableSlotLabels"
:value="slotPickerIndex"
:disabled="!availableTimes.length"
@change="onAppointmentTimeOnlyChange"
class="c-time-half c-time-half--slot"
>
<view class="app-form-input app-form-picker">
<text class="slot-range-text" :class="{ 'app-form-placeholder': !appointmentTime && !loadingSlots }">{{ loadingSlots ? '加载中…' : (selectedSlotRangeLabel || '选择时段') }}</text>
</view>
</picker>
</view>
<text class="c-store-hint">{{ bookingWindowHint }}</text>
<button
v-if="showTryTomorrow"
class="app-btn-sm app-btn-soft c-try-tomorrow"
@click="tryPickTomorrow"
>试选明天</button>
</view>
<view class="c-field">
<text class="app-form-label">备注(可选)</text>
<input v-model="newAppt.remark" class="app-form-input" placeholder="可选" />
</view>
<button class="app-btn-main app-btn-primary" style="margin-top:20px" :loading="creatingAppt" @click="confirmNewAppt">提交预约</button>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import {
createAppointment,
getServiceTypeList,
getPetList,
getStoreList,
createPet,
getAppointmentAvailableSlots
} from '../../api/index.js'
import AppIcon from '../../components/AppIcon.vue'
import { getUserSession, getStoreSession, setStoreSession, getGuestApptDraft, saveGuestApptDraft, clearGuestApptDraft, apptDraftUserKey } from '../../utils/session.js'
import { haversineKm } from '../../utils/geo.js'
import { normalizeToHalfHour, ymdLocal, formatHalfHourSlotRange } from '../../utils/halfHourTime.js'
const userInfo = getUserSession()
const storeInfo = getStoreSession()
const LAST_SUBMITTED_PET_KEY = `petstore_last_submitted_pet_${userInfo.id || 'guest'}`
const BOOKING_STORE_ID_KEY = 'petstore_booking_store_id'
const lastServiceStorageKey = (storeId) =>
`petstore_last_service_${userInfo.id || 'guest'}_${storeId}`
/** 扫码/深链带入的门店 id用于预填并支持折叠展示 */
const routeStoreId = ref('')
const storeExpanded = ref(false)
/**
* 解析小程序码 scene 字段中的 storeId。
* 支持形如 `storeId=1`、`sid=1`、`?storeId=1` 的 query 串;也兼容被 encodeURIComponent 编码过的字符串。
*/
function parseSceneStoreId(scene) {
if (!scene) return ''
let decoded = String(scene)
try { decoded = decodeURIComponent(decoded) } catch (_) {}
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
return params.get('storeId') || params.get('sid') || ''
}
const orderedStores = ref([])
const selectedStoreIndex = ref(0)
const hasUserLocation = ref(false)
onLoad((options) => {
// 读取顺序:普通 storeId query > 小程序码 scene 解码
const directStoreId = options?.storeId != null && String(options.storeId).trim() !== ''
? String(options.storeId).trim()
: ''
routeStoreId.value = directStoreId || parseSceneStoreId(options?.scene)
})
const storeLockedCompact = computed(() => {
if (!routeStoreId.value || !orderedStores.value.length) return false
const cur = orderedStores.value[selectedStoreIndex.value]
return !!(cur && String(cur.id) === String(routeStoreId.value))
})
const useCompactStoreRow = computed(() => {
if (!orderedStores.value.length) return false
if (orderedStores.value.length === 1) return true
return storeLockedCompact.value && !storeExpanded.value
})
const showCollapseStoreLink = computed(
() =>
!!routeStoreId.value &&
orderedStores.value.length > 1 &&
storeLockedCompact.value &&
storeExpanded.value
)
const currentStoreDisplayName = computed(() => {
const row = orderedStores.value[selectedStoreIndex.value]
if (!row) return orderedStores.value.length ? '请选择' : '暂无可选门店'
return row.pickerLabel || row.name || '—'
})
const expandStorePicker = () => {
storeExpanded.value = true
}
const onCollapseStorePicker = () => {
storeExpanded.value = false
}
const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44
const menuRect = uni.getMenuButtonBoundingClientRect?.()
if (menuRect && menuRect.top && menuRect.height) {
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
}
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
})()
const petTypes = [
{ label: '猫', value: '猫' },
{ label: '狗', value: '狗' },
{ label: '其他', value: '其他' }
]
const serviceTypes = ref([])
const storePickerDisplay = computed(() => {
const row = orderedStores.value[selectedStoreIndex.value]
if (!row) return orderedStores.value.length ? '请选择' : '暂无可选门店'
return row.pickerLabel
})
const storeHintLine = computed(() => {
if (!orderedStores.value.length) return ''
if (hasUserLocation.value) return '已根据定位优先展示最近门店,可点击切换'
return '未获取到定位时按列表排序,可手动选择门店'
})
const buildOrderedStores = (raw, lat, lng) => {
const withDist = raw.map((s) => {
const d =
lat != null && lng != null ? haversineKm(lat, lng, s.latitude, s.longitude) : null
return { ...s, distKm: d }
})
withDist.sort((a, b) => {
if (a.distKm == null && b.distKm == null) return (a.name || '').localeCompare(b.name || '', 'zh')
if (a.distKm == null) return 1
if (b.distKm == null) return -1
return a.distKm - b.distKm
})
return withDist.map((s) => ({
...s,
pickerLabel:
s.distKm != null
? `${s.name} · 约${s.distKm < 10 ? s.distKm.toFixed(1) : Math.round(s.distKm)}km`
: s.name
}))
}
const applySelectedStoreToSession = () => {
const s = orderedStores.value[selectedStoreIndex.value]
if (!s?.id) return
uni.setStorageSync(BOOKING_STORE_ID_KEY, s.id)
setStoreSession({
id: s.id,
name: s.name,
address: s.address,
phone: s.phone,
latitude: s.latitude,
longitude: s.longitude,
logo: s.logo
})
}
const initStoresAndLocation = async () => {
const res = await getStoreList()
if (res.code !== 200 || !Array.isArray(res.data) || !res.data.length) {
uni.showToast({ title: '暂无可预约门店', icon: 'none' })
return
}
let lat
let lng
try {
const loc = await new Promise((resolve, reject) => {
uni.getLocation({
type: 'gcj02',
success: resolve,
fail: reject
})
})
lat = loc.latitude
lng = loc.longitude
hasUserLocation.value = true
} catch (_) {
hasUserLocation.value = false
}
orderedStores.value = buildOrderedStores(res.data, lat, lng)
const savedId = uni.getStorageSync(BOOKING_STORE_ID_KEY)
const sid = storeInfo?.id ?? userInfo?.storeId
let pickIdx = 0
const qrIdx =
routeStoreId.value && orderedStores.value.length
? orderedStores.value.findIndex((x) => String(x.id) === String(routeStoreId.value))
: -1
if (qrIdx >= 0) {
pickIdx = qrIdx
storeExpanded.value = false
} else if (savedId && orderedStores.value.some((x) => String(x.id) === String(savedId))) {
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(savedId))
} else if (sid && orderedStores.value.some((x) => String(x.id) === String(sid))) {
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(sid))
}
selectedStoreIndex.value = Math.max(0, pickIdx)
applySelectedStoreToSession()
}
const onStorePickerChange = async (e) => {
selectedStoreIndex.value = Number(e.detail.value)
applySelectedStoreToSession()
newAppt.value.serviceType = ''
await loadServiceTypesForSelected()
await loadAvailableSlots()
}
const myPets = ref([])
const selectedPetId = ref(null)
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
const creatingAppt = ref(false)
/** 与日期选择器同步,避免无号源时丢失已选日期 */
const selectedDateStr = ref(ymdLocal())
const availableTimes = ref([])
const loadingSlots = ref(false)
const bookingWindowHint = ref('请选择半小时时间段;同一时段仅接待一单,已满或已过期不可选')
const normalizeText = (s) => (s || '').toString().trim().toLowerCase()
const findMatchedPet = (name, petType) =>
myPets.value.find(
(p) => normalizeText(p.name) === normalizeText(name) && normalizeText(p.petType) === normalizeText(petType)
)
const applyPetToForm = (pet) => {
if (!pet) return
selectedPetId.value = pet.id || null
newAppt.value.petName = pet.name || ''
newAppt.value.petType = pet.petType || ''
}
const openPetSwitcher = () => {
if (myPets.value.length <= 1) return
uni.showActionSheet({
itemList: myPets.value.map((p) => `${p.name}${p.petType}`),
success: (res) => {
const pet = myPets.value[res.tapIndex]
if (pet) applyPetToForm(pet)
}
})
}
const onPetNameInput = (e) => {
const currentName = e?.detail?.value ?? newAppt.value.petName
if (!selectedPetId.value) return
const cur = myPets.value.find((p) => p.id === selectedPetId.value)
if (!cur) {
selectedPetId.value = null
return
}
if (normalizeText(currentName) !== normalizeText(cur.name)) {
selectedPetId.value = null
}
}
const onPetTypeChange = (e) => {
const i = Number(e.detail.value)
const nextType = petTypes[i]?.value || ''
newAppt.value.petType = nextType
if (!selectedPetId.value) return
const cur = myPets.value.find((p) => p.id === selectedPetId.value)
if (!cur || normalizeText(nextType) !== normalizeText(cur.petType)) {
selectedPetId.value = null
}
}
const appointmentTime = computed(() => {
const raw = newAppt.value.appointmentTime || ''
if (!raw.includes('T')) return ''
const part = (raw.split('T')[1] || '').slice(0, 5)
return normalizeToHalfHour(part)
})
const slotPickerIndex = computed(() => {
const t = appointmentTime.value
const i = availableTimes.value.indexOf(t)
return i >= 0 ? i : 0
})
/** 滚轮选项展示为「09:0009:30」 */
const availableSlotLabels = computed(() =>
availableTimes.value.map((t) => formatHalfHourSlotRange(t))
)
const selectedSlotRangeLabel = computed(() =>
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
)
const showTryTomorrow = computed(
() =>
!loadingSlots.value &&
!!selectedDateStr.value &&
availableTimes.value.length === 0
)
const tryPickTomorrow = () => {
const cur = selectedDateStr.value
if (!cur) return
const parts = cur.split('-').map((x) => parseInt(x, 10))
if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return
const d = new Date(parts[0], parts[1] - 1, parts[2])
if (Number.isNaN(d.getTime())) return
d.setDate(d.getDate() + 1)
selectedDateStr.value = ymdLocal(d)
loadAvailableSlots()
}
const loadAvailableSlots = async () => {
const row = orderedStores.value[selectedStoreIndex.value]
const storeId = row?.id
const date = selectedDateStr.value
if (!storeId || !date) {
availableTimes.value = []
return
}
loadingSlots.value = true
try {
const res = await getAppointmentAvailableSlots(storeId, date)
loadingSlots.value = false
if (res.code !== 200 || !res.data?.slots) {
availableTimes.value = []
uni.showToast({ title: res.message || '号源加载失败', icon: 'none' })
return
}
const ds = res.data.dayStart
const le = res.data.lastSlotStart
if (ds != null && le != null) {
const a = String(ds).slice(0, 5)
const b = String(le).slice(0, 5)
bookingWindowHint.value = `本店可约 ${a}${b};以下为半小时时段,同一时段仅一单,已满或已过不可选`
}
availableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
if (!availableTimes.value.length) {
newAppt.value.appointmentTime = ''
uni.showToast({ title: '当日已无号源,请换一天', icon: 'none' })
return
}
const raw = newAppt.value.appointmentTime || ''
const datePart = raw.includes('T') ? raw.split('T')[0] : ''
const timePart = raw.includes('T')
? normalizeToHalfHour((raw.split('T')[1] || '').slice(0, 5))
: ''
if (datePart === date && timePart && availableTimes.value.includes(timePart)) {
newAppt.value.appointmentTime = `${date}T${timePart}`
} else {
newAppt.value.appointmentTime = `${date}T${availableTimes.value[0]}`
}
} catch (_) {
loadingSlots.value = false
availableTimes.value = []
uni.showToast({ title: '号源加载失败', icon: 'none' })
}
}
const onAppointmentDateChange = (e) => {
const date = e?.detail?.value || ''
if (!date) {
selectedDateStr.value = ''
newAppt.value.appointmentTime = ''
availableTimes.value = []
return
}
selectedDateStr.value = date
loadAvailableSlots()
}
const onAppointmentTimeOnlyChange = (e) => {
const idx = Number(e.detail.value)
const time = availableTimes.value[idx]
const date = selectedDateStr.value
if (!date) {
uni.showToast({ title: '请先选择日期', icon: 'none' })
return
}
if (!time) return
newAppt.value.appointmentTime = `${date}T${time}`
}
const loadServiceTypesForSelected = async () => {
const row = orderedStores.value[selectedStoreIndex.value]
const sid = row?.id
const res = await getServiceTypeList(sid ?? undefined)
if (res.code === 200 && Array.isArray(res.data)) {
serviceTypes.value = res.data.map((s) => ({ label: s.name, value: s.name }))
if (sid) {
try {
const last = uni.getStorageSync(lastServiceStorageKey(sid))
if (last && serviceTypes.value.some((s) => s.value === last)) {
newAppt.value.serviceType = last
}
} catch (_) {}
}
}
}
const hydrateDefaultPet = () => {
if (!myPets.value.length) return
let picked = null
try {
const saved = JSON.parse(uni.getStorageSync(LAST_SUBMITTED_PET_KEY) || 'null')
if (saved) {
if (saved.petId) picked = myPets.value.find((p) => String(p.id) === String(saved.petId)) || null
if (!picked && saved.name && saved.petType) picked = findMatchedPet(saved.name, saved.petType) || null
}
} catch (_) {}
if (!picked) picked = myPets.value[0]
applyPetToForm(picked)
}
const rememberLastSubmittedPet = (pet) => {
if (!pet) return
uni.setStorageSync(
LAST_SUBMITTED_PET_KEY,
JSON.stringify({ petId: pet.id || null, name: pet.name || '', petType: pet.petType || '' })
)
}
const ensurePetProfile = async (name, petType) => {
const current = myPets.value.find((p) => String(p.id) === String(selectedPetId.value))
if (
current &&
normalizeText(current.name) === normalizeText(name) &&
normalizeText(current.petType) === normalizeText(petType)
) {
return current
}
const matched = findMatchedPet(name, petType)
if (matched) {
selectedPetId.value = matched.id || null
return matched
}
const res = await createPet({
ownerUserId: userInfo.id,
name: name.trim(),
petType: petType
})
if (res.code !== 200 || !res.data) {
return null
}
const created = res.data
myPets.value.unshift(created)
selectedPetId.value = created.id || null
return created
}
const confirmNewAppt = async () => {
const a = newAppt.value
if (!a.petName) { uni.showToast({ title: '请输入宠物名字', icon: 'none' }); return }
if (!a.petType) { uni.showToast({ title: '请选择宠物类型', icon: 'none' }); return }
if (!a.serviceType) { uni.showToast({ title: '请选择服务类型', icon: 'none' }); return }
if (!a.appointmentTime) { uni.showToast({ title: '请选择预约时间', icon: 'none' }); return }
if (!availableTimes.value.includes(appointmentTime.value)) {
uni.showToast({ title: '该时段不可预约,请重新选择', icon: 'none' })
await loadAvailableSlots()
return
}
const row = orderedStores.value[selectedStoreIndex.value]
const storeId = row?.id
if (!storeId) {
uni.showToast({ title: '请先选择预约门店', icon: 'none' })
return
}
// 提交时登录策略:未登录先保存 guest 草稿,跳登录页,登录后回到本页并恢复草稿。
if (!userInfo?.id) {
saveApptDraft()
const target = `/pages/appointment/CustAppointmentCreate?storeId=${encodeURIComponent(storeId)}`
const redirect = encodeURIComponent(target)
uni.navigateTo({ url: `/pages/login/Login?redirect=${redirect}` })
return
}
creatingAppt.value = true
const ensuredPet = await ensurePetProfile(a.petName, a.petType)
if (!ensuredPet?.id) {
creatingAppt.value = false
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
return
}
const payload = { ...a, storeId }
payload.petId = ensuredPet.id
const res = await createAppointment(payload)
creatingAppt.value = false
if (res.code === 200) {
rememberLastSubmittedPet(ensuredPet)
try {
uni.setStorageSync(lastServiceStorageKey(storeId), a.serviceType)
} catch (_) {}
try {
const key = apptDraftStorageKey()
if (key) uni.removeStorageSync(key)
clearGuestApptDraft()
} catch (_) {}
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 600)
} else {
uni.showToast({ title: res.message || '创建失败', icon: 'none' })
}
}
const goBack = () => {
const pages = getCurrentPages()
if (pages.length > 1) uni.navigateBack()
else uni.reLaunch({ url: '/pages/home/Home' })
}
const loadMyPets = async () => {
// 未登录时跳过宠物列表请求;提交时登录后会重新加载本页并恢复草稿。
if (!userInfo?.id) {
myPets.value = []
return
}
const res = await getPetList({ ownerUserId: userInfo.id })
if (res.code === 200) {
myPets.value = res.data || []
hydrateDefaultPet()
}
}
/**
* P0 C3草稿策略冻结为「提交时登录 + guest 草稿恢复」。
* - 未登录:写入 guest 草稿 key不依赖 userId
* - 登录后:优先读 guest 草稿恢复,恢复成功后删除 guest 草稿;同时按 userId 维度保存草稿。
* 草稿有效期 7 天。
*/
const apptDraftStorageKey = () => {
const uid = userInfo?.id
return uid ? apptDraftUserKey(uid) : ''
}
const buildDraftPayload = () => {
const row = orderedStores.value[selectedStoreIndex.value]
return {
v: 1,
at: Date.now(),
petName: newAppt.value.petName,
petType: newAppt.value.petType,
serviceType: newAppt.value.serviceType,
appointmentTime: newAppt.value.appointmentTime,
remark: newAppt.value.remark,
selectedDateStr: selectedDateStr.value,
storeIndex: selectedStoreIndex.value,
storeId: row?.id
}
}
const applyDraft = (d) => {
if (!d || d.v !== 1) return
if (typeof d.remark === 'string') newAppt.value.remark = d.remark
if (d.serviceType) newAppt.value.serviceType = d.serviceType
if (d.appointmentTime) newAppt.value.appointmentTime = d.appointmentTime
if (d.petName) newAppt.value.petName = d.petName
if (d.petType) newAppt.value.petType = d.petType
if (typeof d.selectedDateStr === 'string' && d.selectedDateStr) selectedDateStr.value = d.selectedDateStr
if (typeof d.storeIndex === 'number' && orderedStores.value[d.storeIndex]) {
selectedStoreIndex.value = d.storeIndex
applySelectedStoreToSession()
} else if (d.storeId && orderedStores.value.some((x) => String(x.id) === String(d.storeId))) {
selectedStoreIndex.value = orderedStores.value.findIndex((x) => String(x.id) === String(d.storeId))
applySelectedStoreToSession()
}
selectedPetId.value = null
}
const saveApptDraft = () => {
try {
const payload = buildDraftPayload()
// 未登录:只写 guest 草稿;登录态:按 userId 维度保存。
if (!userInfo?.id) {
saveGuestApptDraft(payload)
return
}
const key = apptDraftStorageKey()
if (key) uni.setStorageSync(key, JSON.stringify(payload))
} catch (_) {}
}
const restoreApptDraft = () => {
try {
// 登录后优先恢复 guest 草稿(提交时登录场景),恢复后清理 guest 草稿。
const guest = getGuestApptDraft()
if (guest) {
if (Date.now() - (guest.at || 0) <= 7 * 86400000) {
applyDraft(guest)
}
clearGuestApptDraft()
return
}
if (!userInfo?.id) return
const key = apptDraftStorageKey()
if (!key) return
const raw = uni.getStorageSync(key)
if (!raw) return
const d = JSON.parse(raw)
if (Date.now() - (d.at || 0) > 7 * 86400000) {
uni.removeStorageSync(key)
return
}
applyDraft(d)
} catch (_) {}
}
let apptDraftTimer = null
watch(
() =>
JSON.stringify({
n: newAppt.value,
si: selectedStoreIndex.value,
ds: selectedDateStr.value
}),
() => {
if (apptDraftTimer) clearTimeout(apptDraftTimer)
apptDraftTimer = setTimeout(saveApptDraft, 650)
}
)
onUnload(() => {
saveApptDraft()
})
onMounted(async () => {
selectedDateStr.value = ymdLocal()
await initStoresAndLocation()
if (orderedStores.value.length) {
await loadServiceTypesForSelected()
}
await loadMyPets()
restoreApptDraft()
if (orderedStores.value.length) {
await loadServiceTypesForSelected()
}
await loadAvailableSlots()
})
</script>
<style scoped>
.cust-appt-create {
background: #fafaf8;
min-height: 100vh;
padding-bottom: env(safe-area-inset-bottom);
}
.create-nav {
padding: 14px 16px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 10;
}
.nav-back { font-size: 20px; color: #fff; }
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
.c-hero { padding: 24px 20px 20px; }
.c-hero-title {
display: block;
font-size: 26px;
font-weight: 800;
color: #1a1a1a;
letter-spacing: -0.5px;
}
.c-hero-sub {
display: block;
font-size: 14px;
color: var(--c-text-3);
margin-top: 4px;
}
.c-form-card {
margin: 0 16px;
background: #fff;
border-radius: 16px;
padding: 24px 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
}
.c-field { margin-bottom: 16px; }
.c-time-row { display: flex; gap: 10px; }
.c-time-half { flex: 1; min-width: 0; }
.c-time-half--slot .slot-range-text {
font-size: 15px;
letter-spacing: 0;
}
.pet-name-row {
display: flex;
align-items: center;
gap: 8px;
}
.pet-name-input {
flex: 1;
min-width: 0;
}
.pet-switch-btn {
width: 68px !important;
padding: 0 !important;
color: var(--c-brand) !important;
flex-shrink: 0;
}
.c-store-hint {
display: block;
margin-top: 8px;
font-size: 13px;
color: #666660;
line-height: 1.5;
}
.c-store-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: #f5f5f2;
border-radius: 12px;
}
.c-store-summary-name {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.c-store-change-link {
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: var(--c-brand);
padding: 6px 0 6px 8px;
}
.c-store-collapse {
display: block;
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: var(--c-brand);
}
.c-try-tomorrow {
margin-top: 12px;
width: 100%;
}
</style>