DateSelect.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451
  1. <script setup lang="ts">
  2. import { computed, ref, watch } from 'vue'
  3. import { useI18n } from 'vue-i18n'
  4. import type { PickerOption } from 'vant'
  5. import dayjs from 'dayjs'
  6. type DateRange = {
  7. start?: string // YYYY-MM-DD
  8. end?: string // YYYY-MM-DD
  9. }
  10. type PickerValue = [string, string, string] // day, month, year
  11. const ISO_DATE_FORMAT = 'YYYY-MM-DD'
  12. const DISPLAY_DATE_FORMAT = 'DD/MM/YYYY'
  13. const props = withDefaults(
  14. defineProps<{
  15. show: boolean
  16. value?: DateRange
  17. }>(),
  18. {
  19. value: undefined,
  20. },
  21. )
  22. const emit = defineEmits<{
  23. (e: 'update:show', value: boolean): void
  24. (e: 'update:value', value: DateRange | undefined): void
  25. (e: 'confirm', value: DateRange): void
  26. (e: 'reset'): void
  27. }>()
  28. const { t, locale } = useI18n()
  29. type ActiveField = 'start' | 'end'
  30. const activeField = ref<ActiveField>('start')
  31. const startDate = ref<string | undefined>(props.value?.start)
  32. const endDate = ref<string | undefined>(props.value?.end)
  33. const nativeSafeArea = useState<{ top: number, bottom: number }>('native-safe-area')
  34. const bottomStyle = computed<Partial<Record<string, string>>>(() => {
  35. if (!nativeSafeArea.value) return {}
  36. return {
  37. paddingBottom: `${nativeSafeArea.value.bottom}px`,
  38. }
  39. })
  40. const isIsoDate = (value?: string): value is string => {
  41. return !!value && /^\d{4}-\d{2}-\d{2}$/.test(value)
  42. }
  43. const isValidIsoDate = (iso?: string) => {
  44. if (!isIsoDate(iso)) return false
  45. // Strict parse to avoid overflow dates like 2026-02-31 being treated as valid.
  46. return dayjs(iso, ISO_DATE_FORMAT, true).isValid()
  47. }
  48. const toDisplay = (iso?: string) => {
  49. if (!isIsoDate(iso)) return ''
  50. return dayjs(iso, ISO_DATE_FORMAT).format(DISPLAY_DATE_FORMAT)
  51. }
  52. const toIso = (d: string, m: string, y: string) => `${y}-${m}-${d}`
  53. const parseIsoToPicker = (iso?: string): PickerValue | undefined => {
  54. if (!isIsoDate(iso)) return undefined
  55. const parts = iso.split('-')
  56. if (parts.length !== 3) return undefined
  57. const y = parts[0]!
  58. const m = parts[1]!
  59. const d = parts[2]!
  60. return [d, m, y]
  61. }
  62. const pad2 = (value: string | number) => String(value).padStart(2, '0')
  63. const todayPickerValue = (): PickerValue => {
  64. const now = new Date()
  65. return [pad2(now.getDate()), pad2(now.getMonth() + 1), String(now.getFullYear())]
  66. }
  67. const pickerValue = ref<PickerValue>(todayPickerValue())
  68. watch(
  69. () => props.show,
  70. (show) => {
  71. if (!show) return
  72. // Always re-init internal state from external saved values when opening.
  73. activeField.value = 'start'
  74. startDate.value = props.value?.start
  75. endDate.value = props.value?.end
  76. // If external start is empty, fill today's date as default for the active field.
  77. if (!startDate.value) {
  78. const [d, m, y] = todayPickerValue()
  79. startDate.value = toIso(d, m, y)
  80. }
  81. pickerValue.value = parseIsoToPicker(startDate.value) ?? todayPickerValue()
  82. },
  83. { immediate: true },
  84. )
  85. watch(
  86. () => activeField.value,
  87. () => {
  88. if (!props.show) return
  89. if (activeField.value === 'start') {
  90. if (!startDate.value) {
  91. const [d, m, y] = todayPickerValue()
  92. startDate.value = toIso(d, m, y)
  93. }
  94. pickerValue.value = parseIsoToPicker(startDate.value) ?? todayPickerValue()
  95. return
  96. }
  97. if (!endDate.value) {
  98. const [d, m, y] = todayPickerValue()
  99. endDate.value = toIso(d, m, y)
  100. }
  101. pickerValue.value = parseIsoToPicker(endDate.value) ?? todayPickerValue()
  102. },
  103. )
  104. const monthNames = computed(() => {
  105. // Use i18n locale to render month name similar to design
  106. if (locale.value.startsWith('zh')) {
  107. return Array.from({ length: 12 }, (_, i) => `${i + 1}月`)
  108. }
  109. if (locale.value.startsWith('id')) {
  110. return ['Januari', 'Februari', 'Maret', 'April', 'Mei', 'Juni', 'Juli', 'Agustus', 'September', 'Oktober', 'November', 'Desember']
  111. }
  112. return ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
  113. })
  114. const formatter = (type: string, option: PickerOption) => {
  115. if (type === 'month') {
  116. const idx = Number(option.value ?? 0) - 1
  117. return {
  118. ...option,
  119. text: monthNames.value[idx] ?? String(option.text ?? ''),
  120. }
  121. }
  122. return option
  123. }
  124. const handleChange = (values: unknown) => {
  125. const { selectedValues } = values as { selectedValues: string[] }
  126. const d = selectedValues[0]
  127. const m = selectedValues[1]
  128. const y = selectedValues[2]
  129. if (!d || !m || !y) return
  130. const iso = toIso(pad2(d), pad2(m), y)
  131. if (activeField.value === 'start') startDate.value = iso
  132. else endDate.value = iso
  133. }
  134. const handleReset = () => {
  135. activeField.value = 'start'
  136. startDate.value = undefined
  137. endDate.value = undefined
  138. pickerValue.value = todayPickerValue()
  139. emit('update:value', undefined)
  140. emit('reset')
  141. }
  142. const isRangeValid = computed(() => {
  143. const start = startDate.value
  144. const end = endDate.value
  145. if (start && !isValidIsoDate(start)) return false
  146. if (end && !isValidIsoDate(end)) return false
  147. if (!start || !end) return true
  148. const s = dayjs(start, ISO_DATE_FORMAT, true)
  149. const e = dayjs(end, ISO_DATE_FORMAT, true)
  150. if (!s.isValid() || !e.isValid()) return false
  151. return !e.isBefore(s, 'day')
  152. })
  153. const canSave = computed(() => (!startDate.value && !endDate.value) || ((startDate.value && endDate.value) && isRangeValid.value))
  154. const handleSave = () => {
  155. if (!canSave.value) return
  156. const next: DateRange = { start: startDate.value, end: endDate.value }
  157. emit('update:value', next)
  158. emit('confirm', next)
  159. emit('update:show', false)
  160. }
  161. const handleUpdateShow = (value: boolean) => {
  162. emit('update:show', value)
  163. }
  164. </script>
  165. <template>
  166. <van-popup
  167. :show="props.show"
  168. round
  169. position="bottom"
  170. class="date-select-popup"
  171. :overlay="true"
  172. :overlay-style="{ backgroundColor: 'rgba(0,0,0,0.4)' }"
  173. @update:show="handleUpdateShow"
  174. >
  175. <div class="date-select-popup__container">
  176. <header class="date-select-popup__header">
  177. <h3 class="date-select-popup__title">
  178. {{ t('wallet.dateSelect.title') }}
  179. </h3>
  180. <button
  181. type="button"
  182. class="date-select-popup__reset"
  183. @click="handleReset"
  184. >
  185. {{ t('wallet.dateSelect.reset') }}
  186. </button>
  187. </header>
  188. <section class="date-select-popup__range">
  189. <button
  190. type="button"
  191. class="date-select-popup__pill"
  192. :class="{ 'date-select-popup__pill--active': activeField === 'start',
  193. 'date-select-popup__pill--invalid': activeField === 'start' && !isRangeValid }"
  194. @click="activeField = 'start'"
  195. >
  196. <span
  197. class="date-select-popup__pill-text"
  198. :class="{ 'date-select-popup__pill-text--placeholder': !startDate }"
  199. >
  200. {{ startDate ? toDisplay(startDate) : t('wallet.dateSelect.startPlaceholder') }}
  201. </span>
  202. </button>
  203. <span class="date-select-popup__dash" />
  204. <button
  205. type="button"
  206. class="date-select-popup__pill"
  207. :class="{ 'date-select-popup__pill--active': activeField === 'end',
  208. 'date-select-popup__pill--invalid': activeField === 'end' && !isRangeValid }"
  209. @click="activeField = 'end'"
  210. >
  211. <span
  212. class="date-select-popup__pill-text"
  213. :class="{ 'date-select-popup__pill-text--placeholder': !endDate }"
  214. >
  215. {{ endDate ? toDisplay(endDate) : t('wallet.dateSelect.endPlaceholder') }}
  216. </span>
  217. </button>
  218. </section>
  219. <section class="date-select-popup__picker">
  220. <van-date-picker
  221. v-model="pickerValue"
  222. :columns-type="['day', 'month', 'year']"
  223. :formatter="formatter"
  224. :show-toolbar="false"
  225. @change="handleChange"
  226. />
  227. </section>
  228. <footer
  229. class="date-select-popup__footer"
  230. :style="bottomStyle"
  231. >
  232. <button
  233. type="button"
  234. class="date-select-popup__save"
  235. :class="{ 'date-select-popup__save--disabled': !canSave }"
  236. :disabled="!canSave"
  237. @click="handleSave"
  238. >
  239. {{ t('wallet.dateSelect.save') }}
  240. </button>
  241. </footer>
  242. </div>
  243. </van-popup>
  244. </template>
  245. <style scoped lang="scss">
  246. .date-select-popup {
  247. background-color: transparent;
  248. &__container {
  249. background: #fff;
  250. border-radius: 12px 12px 0 0;
  251. overflow: hidden;
  252. width: 100%;
  253. }
  254. &__header {
  255. height: 50px;
  256. padding: 0 16px;
  257. display: flex;
  258. align-items: center;
  259. justify-content: center;
  260. position: relative;
  261. background: #fff;
  262. }
  263. &__title {
  264. margin: 0;
  265. font-family: var(--font-title);
  266. font-size: 16px;
  267. font-weight: 600;
  268. color: var(--color-text-primary);
  269. line-height: 17px;
  270. }
  271. &__reset {
  272. position: absolute;
  273. right: 16px;
  274. top: 50%;
  275. transform: translateY(-50%);
  276. border: none;
  277. background: transparent;
  278. padding: 0;
  279. font-size: 12px;
  280. font-weight: 600;
  281. color: var(--color-text-secondary);
  282. -webkit-tap-highlight-color: transparent;
  283. }
  284. &__range {
  285. height: 49px;
  286. padding: 6px 16px;
  287. display: flex;
  288. align-items: center;
  289. justify-content: space-between;
  290. gap: 10px;
  291. background: #fff;
  292. }
  293. &__pill {
  294. height: 37px;
  295. width: 149px;
  296. border-radius: 30px;
  297. background: #f9fafb;
  298. border: 1px solid transparent;
  299. display: flex;
  300. align-items: center;
  301. justify-content: center;
  302. padding: 0 12px;
  303. -webkit-tap-highlight-color: transparent;
  304. }
  305. &__pill--active {
  306. border-color: #1789ff;
  307. }
  308. &__pill--invalid {
  309. border-color: #ff4d4f !important;
  310. }
  311. &__pill-text {
  312. font-size: 14px;
  313. font-weight: 600;
  314. color: var(--color-text-primary);
  315. }
  316. &__pill-text--placeholder {
  317. color: #c9cdd4;
  318. }
  319. &__dash {
  320. width: 16px;
  321. height: 2px;
  322. background: #86909c;
  323. opacity: 0.6;
  324. border-radius: 2px;
  325. flex: none;
  326. }
  327. &__picker {
  328. height: 250px;
  329. padding: 10px 0;
  330. background: #fff;
  331. display: flex;
  332. align-items: center;
  333. justify-content: center;
  334. overflow: hidden;
  335. }
  336. &__footer {
  337. height: 54px;
  338. padding: 3px 16px 4px;
  339. background: #fff;
  340. box-sizing: content-box;
  341. }
  342. &__save {
  343. width: 100%;
  344. height: 47px;
  345. border: none;
  346. border-radius: 100px;
  347. background: linear-gradient(90deg, #2f95ff 28.365%, #50ffd8 100%);
  348. color: #fff;
  349. font-family: var(--font-title);
  350. font-size: 16px;
  351. font-weight: 600;
  352. -webkit-tap-highlight-color: transparent;
  353. }
  354. &__save--disabled,
  355. &__save:disabled {
  356. opacity: 0.5;
  357. }
  358. }
  359. /* Vant picker style overrides */
  360. :deep(.van-picker) {
  361. flex: 1;
  362. }
  363. :deep(.van-picker-column__item) {
  364. font-size: 18px;
  365. color: var(--color-text-primary);
  366. }
  367. :deep(.van-picker-column__item--selected) {
  368. font-size: 22px;
  369. font-weight: 400;
  370. }
  371. /* Highlight bar */
  372. :deep(.van-picker-column) {
  373. z-index: 1;
  374. }
  375. :deep(.van-picker__frame) {
  376. z-index: 0;
  377. background: #e6fffa;
  378. border-radius: 12px;
  379. }
  380. :deep(.van-picker__frame:after) {
  381. border-width: 0;
  382. }
  383. </style>