返回 AiToEarn
WxSphParams.tsx
1 import type { ForwardedRef } from 'react'
2 import type {
3 IPlatsParamsProps,
4 IPlatsParamsRef,
5 } from '@/components/PublishDialog/compoents/PlatParamsSetting/plats/plats.type'
6 import type {
7 IPlatOption,
8 IWxSphEventInfo,
9 IWxSphPoiInfo,
10 } from '@/components/PublishDialog/publishDialog.type'
11 import { Check, ChevronsUpDown, Loader2, MapPin, Search } from 'lucide-react'
12 import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'
13 import { useTransClient } from '@/app/i18n/client'
14 import usePlatParamsCommon from '@/components/PublishDialog/compoents/PlatParamsSetting/hooks/usePlatParamsCoomon'
15 import PubParmasTextarea from '@/components/PublishDialog/compoents/PubParmasTextarea'
16 import { Button } from '@/components/ui/button'
17 import { Checkbox } from '@/components/ui/checkbox'
18 import {
19 Command,
20 CommandEmpty,
21 CommandInput,
22 CommandItem,
23 CommandList,
24 } from '@/components/ui/command'
25 import { Label } from '@/components/ui/label'
26 import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
27 import { cn } from '@/utils/className'
28 import CommonTitleInput from '../common/CommonTitleInput'
29
30 interface WxSphLocationSearchItem {
31 uid: string
32 name: string
33 longitude: number
34 latitude: number
35 address?: string
36 province?: string
37 city?: string
38 region?: string
39 fullAddress?: string
40 poiCheckSum?: string
41 }
42
43 type WxSphOptionPatch = NonNullable<IPlatOption['wxSph']>
44
45 function buildWxSphOption(option: IPlatOption, patch: Partial<WxSphOptionPatch>): IPlatOption {
46 const wxSphOption = { ...option.wxSph }
47 delete wxSphOption.extLink
48
49 return {
50 ...option,
51 wxSph: {
52 ...wxSphOption,
53 ...patch,
54 },
55 }
56 }
57
58 function mapLocationToPoiInfo(item: WxSphLocationSearchItem): IWxSphPoiInfo {
59 return {
60 latitude: item.latitude,
61 longitude: item.longitude,
62 poiCity: item.city,
63 poiName: item.name,
64 poiAddress: item.fullAddress || item.address,
65 poiId: item.uid,
66 province: item.province,
67 region: item.region,
68 fullAddress: item.fullAddress,
69 poiCheckSum: item.poiCheckSum,
70 }
71 }
72
73 function getLocationDescription(poiInfo?: IWxSphPoiInfo) {
74 if (!poiInfo)
75 return ''
76 return poiInfo.fullAddress || poiInfo.poiAddress || poiInfo.poiCity || ''
77 }
78
79 function getLocationTitle(poiInfo?: IWxSphPoiInfo) {
80 if (!poiInfo)
81 return ''
82 return poiInfo.poiName || poiInfo.fullAddress || poiInfo.poiAddress || ''
83 }
84
85 function getSearchErrorCode(error: unknown) {
86 if (!error || typeof error !== 'object')
87 return undefined
88
89 const candidate = error as { code?: unknown }
90 return typeof candidate.code === 'string' ? candidate.code : undefined
91 }
92
93 const WxSphParams = memo(
94 forwardRef(
95 (
96 { pubItem, isMobile }: IPlatsParamsProps,
97 _ref: ForwardedRef<IPlatsParamsRef>,
98 ) => {
99 const { t } = useTransClient('publish')
100 const { pubParmasTextareaCommonParams, setOnePubParams } = usePlatParamsCommon(
101 pubItem,
102 isMobile,
103 )
104 const wxSphOption = pubItem.params.option.wxSph ?? {}
105 const poiInfo = wxSphOption.poiInfo
106 const activity = wxSphOption.activity
107 const [locationOpen, setLocationOpen] = useState(false)
108 const [locationQuery, setLocationQuery] = useState('')
109 const [locationLoading, setLocationLoading] = useState(false)
110 const [locationResults, setLocationResults] = useState<WxSphLocationSearchItem[]>([])
111 const [locationError, setLocationError] = useState('')
112 const locationCacheRef = useRef(new Map<string, WxSphLocationSearchItem[]>())
113 const [activityOpen, setActivityOpen] = useState(false)
114 const [activityQuery, setActivityQuery] = useState('')
115 const [activityLoading, setActivityLoading] = useState(false)
116 const [activityResults, setActivityResults] = useState<IWxSphEventInfo[]>([])
117 const [activityError, setActivityError] = useState('')
118 const activityCacheRef = useRef(new Map<string, IWxSphEventInfo[]>())
119
120 const selectedLocationTitle = useMemo(() => getLocationTitle(poiInfo), [poiInfo])
121 const selectedLocationDescription = useMemo(() => getLocationDescription(poiInfo), [poiInfo])
122
123 const updateWxSphOption = useCallback(
124 (patch: Partial<WxSphOptionPatch>) => {
125 const option = buildWxSphOption(pubItem.params.option, patch)
126 setOnePubParams({ option }, pubItem.account.id)
127 },
128 [pubItem.account.id, pubItem.params.option, setOnePubParams],
129 )
130
131 const clearPoiInfo = useCallback(() => {
132 updateWxSphOption({ poiInfo: undefined })
133 setLocationQuery('')
134 setLocationResults([])
135 setLocationError('')
136 }, [updateWxSphOption])
137
138 const clearActivity = useCallback(() => {
139 updateWxSphOption({ activity: undefined })
140 setActivityQuery('')
141 setActivityResults([])
142 setActivityError('')
143 }, [updateWxSphOption])
144
145 useEffect(() => {
146 if (wxSphOption.extLink !== undefined)
147 updateWxSphOption({})
148 }, [updateWxSphOption, wxSphOption.extLink])
149
150 useEffect(() => {
151 const query = locationQuery.trim()
152 if (!locationOpen) {
153 setLocationLoading(false)
154 return
155 }
156
157 if (query.length < 2) {
158 setLocationResults([])
159 setLocationLoading(false)
160 setLocationError('')
161 return
162 }
163
164 const cachedResults = locationCacheRef.current.get(query)
165 if (cachedResults) {
166 setLocationResults(cachedResults)
167 setLocationLoading(false)
168 setLocationError('')
169 return
170 }
171
172 const plugin = typeof window !== 'undefined' ? window.AIToEarnPlugin : undefined
173 const searchLocation = plugin?.wxSphSearchLocation?.bind(plugin)
174 if (!searchLocation) {
175 setLocationResults([])
176 setLocationLoading(false)
177 setLocationError(t('wxSph.search.pluginUnavailable'))
178 return
179 }
180
181 let cancelled = false
182 setLocationLoading(true)
183 setLocationError('')
184
185 const timer = window.setTimeout(async () => {
186 try {
187 const results = await searchLocation({ query })
188 if (!cancelled) {
189 locationCacheRef.current.set(query, results)
190 setLocationResults(results)
191 }
192 }
193 catch (error) {
194 if (!cancelled) {
195 const code = getSearchErrorCode(error)
196 setLocationResults([])
197 setLocationError(
198 code
199 ? `${t('wxSph.location.searchFailed')} (${code})`
200 : t('wxSph.location.searchFailed'),
201 )
202 }
203 }
204 finally {
205 if (!cancelled)
206 setLocationLoading(false)
207 }
208 }, 350)
209
210 return () => {
211 cancelled = true
212 window.clearTimeout(timer)
213 }
214 }, [locationOpen, locationQuery, t])
215
216 useEffect(() => {
217 const query = activityQuery.trim()
218 if (!activityOpen) {
219 setActivityLoading(false)
220 return
221 }
222
223 if (query.length < 2) {
224 setActivityResults([])
225 setActivityLoading(false)
226 setActivityError('')
227 return
228 }
229
230 const cachedResults = activityCacheRef.current.get(query)
231 if (cachedResults) {
232 setActivityResults(cachedResults)
233 setActivityLoading(false)
234 setActivityError('')
235 return
236 }
237
238 const plugin = typeof window !== 'undefined' ? window.AIToEarnPlugin : undefined
239 const searchActivity = plugin?.wxSphSearchActivity?.bind(plugin)
240 if (!searchActivity) {
241 setActivityResults([])
242 setActivityLoading(false)
243 setActivityError(t('wxSph.search.pluginUnavailable'))
244 return
245 }
246
247 let cancelled = false
248 setActivityLoading(true)
249 setActivityError('')
250
251 const timer = window.setTimeout(async () => {
252 try {
253 const results = await searchActivity({ query })
254 if (!cancelled) {
255 activityCacheRef.current.set(query, results)
256 setActivityResults(results)
257 }
258 }
259 catch (error) {
260 if (!cancelled) {
261 const code = getSearchErrorCode(error)
262 setActivityResults([])
263 setActivityError(
264 code
265 ? `${t('wxSph.activity.searchFailed')} (${code})`
266 : t('wxSph.activity.searchFailed'),
267 )
268 }
269 }
270 finally {
271 if (!cancelled)
272 setActivityLoading(false)
273 }
274 }, 350)
275
276 return () => {
277 cancelled = true
278 window.clearTimeout(timer)
279 }
280 }, [activityOpen, activityQuery, t])
281
282 return (
283 <PubParmasTextarea
284 {...pubParmasTextareaCommonParams}
285 extend={(
286 <>
287 <CommonTitleInput pubItem={pubItem} isMobile={isMobile} />
288
289 <div className="mt-4 grid grid-cols-[84px_minmax(0,1fr)] items-start gap-3">
290 <Label className="pt-2 text-sm font-medium text-foreground">
291 {t('wxSph.location.title')}
292 </Label>
293
294 <div className="flex min-w-0 gap-2">
295 <Popover open={locationOpen} onOpenChange={setLocationOpen}>
296 <PopoverTrigger asChild>
297 <Button
298 type="button"
299 variant="outline"
300 role="combobox"
301 aria-expanded={locationOpen}
302 className="h-auto min-h-10 w-full justify-between gap-3 rounded-md bg-background px-3 py-2 text-left font-normal"
303 >
304 <span className="flex min-w-0 items-start gap-2">
305 <MapPin className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
306 <span className="min-w-0">
307 <span
308 className={cn(
309 'block truncate text-sm',
310 !selectedLocationTitle && 'text-muted-foreground',
311 )}
312 >
313 {selectedLocationTitle || t('wxSph.location.searchPlaceholder')}
314 </span>
315 {selectedLocationDescription && (
316 <span className="mt-0.5 block truncate text-xs text-muted-foreground">
317 {selectedLocationDescription}
318 </span>
319 )}
320 </span>
321 </span>
322 <ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
323 </Button>
324 </PopoverTrigger>
325 <PopoverContent
326 className="w-[var(--radix-popover-trigger-width)] p-0"
327 align="start"
328 allowInnerScroll
329 >
330 <Command shouldFilter={false}>
331 <CommandInput
332 value={locationQuery}
333 placeholder={t('wxSph.location.searchPlaceholder')}
334 onValueChange={setLocationQuery}
335 />
336 <CommandList>
337 {locationLoading && (
338 <div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
339 <Loader2 className="h-4 w-4 animate-spin" />
340 <span>{t('wxSph.search.loading')}</span>
341 </div>
342 )}
343 <CommandEmpty>
344 {locationQuery.trim().length < 2
345 ? t('wxSph.search.minKeyword')
346 : locationError || t('wxSph.location.empty')}
347 </CommandEmpty>
348 {locationResults.map(item => (
349 <CommandItem
350 key={item.uid}
351 value={`${item.uid}-${item.name}`}
352 className="items-start gap-2 py-2"
353 onSelect={() => {
354 updateWxSphOption({ poiInfo: mapLocationToPoiInfo(item) })
355 locationCacheRef.current.set(item.name, [item])
356 setLocationResults([item])
357 setLocationError('')
358 setLocationOpen(false)
359 setLocationQuery(item.name)
360 }}
361 >
362 <Check
363 className={cn(
364 'mt-0.5 h-4 w-4 shrink-0',
365 poiInfo?.poiId === item.uid ? 'opacity-100' : 'opacity-0',
366 )}
367 />
368 <span className="min-w-0 flex-1">
369 <span className="block truncate text-sm text-foreground">
370 {item.name}
371 </span>
372 <span className="mt-0.5 block truncate text-xs text-muted-foreground">
373 {item.fullAddress
374 || item.address
375 || item.city
376 || t('wxSph.location.addressEmpty')}
377 </span>
378 </span>
379 </CommandItem>
380 ))}
381 </CommandList>
382 </Command>
383 </PopoverContent>
384 </Popover>
385
386 {poiInfo?.poiId && (
387 <Button
388 type="button"
389 variant="ghost"
390 size="sm"
391 className="h-10 shrink-0 cursor-pointer px-2 text-xs text-muted-foreground hover:text-foreground"
392 onClick={clearPoiInfo}
393 >
394 {t('wxSph.location.clear')}
395 </Button>
396 )}
397 </div>
398 </div>
399
400 <div className="mt-4 grid grid-cols-[84px_minmax(0,1fr)] items-start gap-3">
401 <Label className="pt-2 text-sm font-medium text-foreground">
402 {t('wxSph.activity.title')}
403 </Label>
404
405 <div className="flex min-w-0 gap-2">
406 <Popover open={activityOpen} onOpenChange={setActivityOpen}>
407 <PopoverTrigger asChild>
408 <Button
409 type="button"
410 variant="outline"
411 role="combobox"
412 aria-expanded={activityOpen}
413 className="h-10 w-full justify-between gap-3 rounded-md bg-background px-3 text-left font-normal"
414 >
415 <span className="flex min-w-0 items-center gap-2">
416 <Search className="h-4 w-4 shrink-0 text-muted-foreground" />
417 <span
418 className={cn(
419 'truncate text-sm',
420 !activity?.eventName && 'text-muted-foreground',
421 )}
422 >
423 {activity?.eventName || t('wxSph.activity.searchPlaceholder')}
424 </span>
425 </span>
426 <ChevronsUpDown className="h-4 w-4 shrink-0 text-muted-foreground" />
427 </Button>
428 </PopoverTrigger>
429 <PopoverContent
430 className="w-[var(--radix-popover-trigger-width)] p-0"
431 align="start"
432 allowInnerScroll
433 >
434 <Command shouldFilter={false}>
435 <CommandInput
436 value={activityQuery}
437 placeholder={t('wxSph.activity.searchPlaceholder')}
438 onValueChange={setActivityQuery}
439 />
440 <CommandList>
441 {activityLoading && (
442 <div className="flex items-center gap-2 px-3 py-3 text-sm text-muted-foreground">
443 <Loader2 className="h-4 w-4 animate-spin" />
444 <span>{t('wxSph.search.loading')}</span>
445 </div>
446 )}
447 <CommandEmpty>
448 {activityQuery.trim().length < 2
449 ? t('wxSph.search.minKeyword')
450 : activityError || t('wxSph.activity.empty')}
451 </CommandEmpty>
452 {activityResults.map(item => (
453 <CommandItem
454 key={item.eventTopicId}
455 value={`${item.eventTopicId}-${item.eventName}`}
456 className="items-start gap-2 py-2"
457 onSelect={() => {
458 updateWxSphOption({ activity: item })
459 activityCacheRef.current.set(item.eventName, [item])
460 setActivityResults([item])
461 setActivityError('')
462 setActivityOpen(false)
463 setActivityQuery(item.eventName)
464 }}
465 >
466 <Check
467 className={cn(
468 'mt-0.5 h-4 w-4 shrink-0',
469 activity?.eventTopicId === item.eventTopicId
470 ? 'opacity-100'
471 : 'opacity-0',
472 )}
473 />
474 <span className="min-w-0 flex-1">
475 <span className="block truncate text-sm text-foreground">
476 {item.eventName}
477 </span>
478 {item.eventCreatorNickname && (
479 <span className="mt-0.5 block truncate text-xs text-muted-foreground">
480 {item.eventCreatorNickname}
481 </span>
482 )}
483 </span>
484 </CommandItem>
485 ))}
486 </CommandList>
487 </Command>
488 </PopoverContent>
489 </Popover>
490
491 {activity?.eventTopicId && (
492 <Button
493 type="button"
494 variant="ghost"
495 size="sm"
496 className="h-10 shrink-0 cursor-pointer px-2 text-xs text-muted-foreground hover:text-foreground"
497 onClick={clearActivity}
498 >
499 {t('wxSph.activity.clear')}
500 </Button>
501 )}
502 </div>
503 </div>
504
505 <div className="mt-4 grid grid-cols-[84px_minmax(0,1fr)] items-start gap-3">
506 <Label className="pt-2 text-sm font-medium text-foreground">
507 {t('wxSph.original.title')}
508 </Label>
509 <div className="flex min-h-10 items-center gap-2 rounded-md border border-border bg-background px-3 py-2">
510 <Checkbox
511 id={`wx-sph-original-${pubItem.account.id}`}
512 checked={!!wxSphOption.isOriginal}
513 className="h-4 w-4 rounded border-border bg-background text-primary shadow-none data-[state=checked]:border-primary/70 data-[state=checked]:bg-background data-[state=checked]:text-primary"
514 onCheckedChange={checked =>
515 updateWxSphOption({ isOriginal: checked === true })}
516 />
517 <Label
518 htmlFor={`wx-sph-original-${pubItem.account.id}`}
519 className="cursor-pointer text-xs leading-5 text-muted-foreground"
520 >
521 {t('wxSph.original.hint')}
522 </Label>
523 </div>
524 </div>
525 </>
526 )}
527 />
528 )
529 },
530 ),
531 )
532
533 export default WxSphParams
534
534 lines Plain Text