| 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 |