Skip to content

Commit a5b1e17

Browse files
committed
feat(web): implement URL parsing in item entry modal and enhance form handling
1 parent b85d480 commit a5b1e17

9 files changed

Lines changed: 292 additions & 81 deletions

File tree

apps/web/components/items/ItemEntryModal.vue

Lines changed: 126 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,28 +2,82 @@
22
<component
33
:is="dialogComponent"
44
v-model:open="open"
5-
:title="t('items.entry_title')"
5+
:title="phase === 'idle' ? t('items.entry_title') : ''"
6+
:dismissible="phase === 'idle'"
67
>
78
<template #body>
8-
<div class="space-y-4">
9-
<UAlert
10-
v-if="isUrl"
11-
color="neutral"
12-
variant="soft"
13-
:description="t('items.entry_url_note')"
14-
/>
15-
<UFormField :label="t('items.fields.title')">
16-
<UInput
17-
v-model="input"
18-
:placeholder="t('items.entry_placeholder')"
19-
autofocus
20-
@keydown.enter="proceed"
9+
<Transition
10+
mode="out-in"
11+
enter-active-class="animate-in fade-in-0 slide-in-from-bottom-2 duration-200 ease-out"
12+
leave-active-class="animate-out fade-out-0 slide-out-to-top-2 duration-150 ease-in"
13+
>
14+
<!-- idle -->
15+
<div v-if="phase === 'idle'" key="idle" class="space-y-4">
16+
<UFormField :label="t('items.fields.title')">
17+
<UInput
18+
v-model="input"
19+
:placeholder="t('items.entry_placeholder')"
20+
autofocus
21+
@keydown.enter="proceed"
22+
/>
23+
</UFormField>
24+
</div>
25+
26+
<!-- scanning -->
27+
<div v-else-if="phase === 'scanning'" key="scanning" class="flex flex-col items-center py-6 text-center">
28+
<UIcon
29+
name="i-heroicons-arrow-path"
30+
class="h-8 w-8 animate-spin text-black dark:text-white"
31+
/>
32+
<div class="mt-4 space-y-1">
33+
<p class="text-sm font-semibold text-black dark:text-white">
34+
{{ t('items.scanning.title') }}
35+
</p>
36+
<p class="max-w-[200px] truncate text-xs text-muted-gray">
37+
{{ hostname }}
38+
</p>
39+
</div>
40+
</div>
41+
42+
<!-- success -->
43+
<div v-else-if="phase === 'success'" key="success" class="flex flex-col items-center py-6 text-center">
44+
<UIcon
45+
name="i-heroicons-check-circle"
46+
class="h-8 w-8 text-black dark:text-white"
47+
style="animation: wp-spin-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both"
48+
/>
49+
<div class="mt-4 space-y-1">
50+
<p class="text-sm font-semibold text-black dark:text-white">
51+
{{ t('items.scanning.success') }}
52+
</p>
53+
</div>
54+
</div>
55+
56+
<!-- error -->
57+
<div v-else key="error" class="flex w-full flex-col items-center gap-4 py-6 text-center">
58+
<UIcon
59+
name="i-heroicons-exclamation-circle"
60+
class="h-8 w-8 text-black dark:text-white"
61+
/>
62+
<div class="space-y-1">
63+
<p class="text-sm font-semibold text-black dark:text-white">
64+
{{ t('items.scanning.error') }}
65+
</p>
66+
<p class="text-xs text-muted-gray">
67+
{{ t('items.scanning.error_body') }}
68+
</p>
69+
</div>
70+
<UButton
71+
class="w-full"
72+
color="neutral"
73+
:label="t('items.scanning.proceed')"
74+
@click="onErrorProceed"
2175
/>
22-
</UFormField>
23-
</div>
76+
</div>
77+
</Transition>
2478
</template>
2579

26-
<template #footer>
80+
<template v-if="phase === 'idle'" #footer>
2781
<UButton
2882
class="w-full"
2983
:disabled="!input.trim()"
@@ -36,10 +90,15 @@
3690

3791
<script setup lang="ts">
3892
import { resolveComponent } from 'vue'
93+
import type { ParseUrlData } from '~/types/api'
94+
95+
const props = defineProps<{
96+
parseUrlFn?: (url: string) => Promise<ParseUrlData>
97+
}>()
3998
4099
const open = defineModel<boolean>('open', { default: false })
41100
const emit = defineEmits<{
42-
proceed: [payload: { title: string; productUrl: string | null }]
101+
proceed: [payload: { title: string; productUrl: string | null; parsedData?: ParseUrlData | null }]
43102
}>()
44103
45104
const { t } = useI18n()
@@ -54,25 +113,68 @@ onMounted(() => {
54113
55114
const dialogComponent = computed(() => (isMobile.value ? UDrawer : UModal))
56115
116+
type Phase = 'idle' | 'scanning' | 'success' | 'error'
117+
const phase = ref<Phase>('idle')
57118
const input = ref('')
119+
const scannedData = ref<ParseUrlData | null>(null)
58120
59121
const isUrl = computed(() => {
60122
const v = input.value.trim()
61123
return v.startsWith('http://') || v.startsWith('https://')
62124
})
63125
64-
function proceed() {
126+
const hostname = computed(() => {
127+
try {
128+
return new URL(input.value.trim()).hostname.replace('www.', '')
129+
} catch {
130+
return input.value.trim()
131+
}
132+
})
133+
134+
async function proceed() {
65135
const v = input.value.trim()
66136
if (!v) return
67-
emit('proceed', {
68-
title: isUrl.value ? '' : v,
69-
productUrl: isUrl.value ? v : null,
70-
})
137+
138+
if (isUrl.value && props.parseUrlFn) {
139+
phase.value = 'scanning'
140+
try {
141+
const data = await props.parseUrlFn(v)
142+
scannedData.value = data
143+
phase.value = 'success'
144+
setTimeout(() => {
145+
emit('proceed', { title: '', productUrl: v, parsedData: data })
146+
reset()
147+
}, 800)
148+
} catch {
149+
phase.value = 'error'
150+
}
151+
} else {
152+
emit('proceed', {
153+
title: isUrl.value ? '' : v,
154+
productUrl: isUrl.value ? v : null,
155+
})
156+
reset()
157+
}
158+
}
159+
160+
function onErrorProceed() {
161+
const v = input.value.trim()
162+
emit('proceed', { title: '', productUrl: v, parsedData: null })
163+
reset()
164+
}
165+
166+
function reset() {
71167
input.value = ''
72168
open.value = false
169+
phase.value = 'idle'
170+
scannedData.value = null
73171
}
74172
75173
watch(open, (val) => {
76-
if (!val) input.value = ''
174+
if (!val) {
175+
input.value = ''
176+
phase.value = 'idle'
177+
scannedData.value = null
178+
}
77179
})
78180
</script>

0 commit comments

Comments
 (0)