shared-link-card.tsx 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. 'use client'
  2. import { useState } from 'react'
  3. import { Link2, Copy, Check, Trash2, Loader2 } from 'lucide-react'
  4. import { useTranslations } from 'next-intl'
  5. interface SharedLinkCardProps {
  6. publicToken: string
  7. organizationId: string
  8. type: 'quote' | 'invoice'
  9. sharedAt: Date | null
  10. viewCount: number
  11. lastViewedAt: Date | null
  12. onRevoke?: () => Promise<void>
  13. }
  14. export function SharedLinkCard({
  15. publicToken,
  16. organizationId,
  17. type,
  18. sharedAt,
  19. viewCount,
  20. lastViewedAt,
  21. onRevoke,
  22. }: SharedLinkCardProps) {
  23. const t = useTranslations(type === 'quote' ? 'quotes' : 'service')
  24. const [copied, setCopied] = useState(false)
  25. const [revoking, setRevoking] = useState(false)
  26. const baseUrl =
  27. process.env.NEXT_PUBLIC_APP_URL || (typeof window !== 'undefined' ? window.location.origin : '')
  28. const publicUrl = `${baseUrl}/share/${type === 'quote' ? 'quote' : 'invoice'}/${organizationId}/${publicToken}`
  29. const handleCopy = async () => {
  30. try {
  31. await navigator.clipboard.writeText(publicUrl)
  32. setCopied(true)
  33. setTimeout(() => setCopied(false), 2000)
  34. } catch {
  35. // silent
  36. }
  37. }
  38. const handleRevoke = async () => {
  39. if (!onRevoke) return
  40. setRevoking(true)
  41. try {
  42. await onRevoke()
  43. } finally {
  44. setRevoking(false)
  45. }
  46. }
  47. const hasViews = viewCount > 0
  48. return (
  49. <div className="rounded-lg border p-3 space-y-2">
  50. <div className="flex items-center gap-2">
  51. <Link2 className="h-3.5 w-3.5 text-muted-foreground" />
  52. <h3 className="flex-1 text-sm font-semibold">{t('sidebar.sharedLink.title')}</h3>
  53. {onRevoke && (
  54. <button
  55. type="button"
  56. onClick={handleRevoke}
  57. disabled={revoking}
  58. className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
  59. >
  60. {revoking ? (
  61. <Loader2 className="h-3.5 w-3.5 animate-spin" />
  62. ) : (
  63. <Trash2 className="h-3.5 w-3.5" />
  64. )}
  65. </button>
  66. )}
  67. </div>
  68. {/* Public URL with copy */}
  69. <div className="flex items-center gap-1.5">
  70. <div className="min-w-0 flex-1 truncate rounded-md bg-muted/50 px-2.5 py-1.5 font-mono text-xs text-muted-foreground">
  71. {publicUrl}
  72. </div>
  73. <button
  74. type="button"
  75. onClick={handleCopy}
  76. className="shrink-0 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
  77. >
  78. {copied ? (
  79. <Check className="h-3.5 w-3.5 text-emerald-500" />
  80. ) : (
  81. <Copy className="h-3.5 w-3.5" />
  82. )}
  83. </button>
  84. </div>
  85. {/* Shared date */}
  86. {sharedAt && (
  87. <p className="text-xs text-muted-foreground" suppressHydrationWarning>
  88. {t('sidebar.sharedLink.sharedOn', {
  89. date: new Date(sharedAt).toLocaleDateString(undefined, {
  90. month: 'short',
  91. day: 'numeric',
  92. year: 'numeric',
  93. }),
  94. })}
  95. </p>
  96. )}
  97. {/* View status */}
  98. <div className="flex items-center gap-1.5">
  99. <span
  100. className={`inline-block h-2 w-2 rounded-full ${
  101. hasViews ? 'bg-emerald-500' : 'bg-gray-300 dark:bg-gray-600'
  102. }`}
  103. />
  104. {hasViews ? (
  105. <span className="text-xs text-muted-foreground" suppressHydrationWarning>
  106. {t('sidebar.sharedLink.viewedTimes', { count: viewCount })}
  107. {lastViewedAt && (
  108. <>
  109. {' · '}
  110. {t('sidebar.sharedLink.lastViewed', {
  111. date: new Date(lastViewedAt).toLocaleDateString(undefined, {
  112. month: 'short',
  113. day: 'numeric',
  114. hour: '2-digit',
  115. minute: '2-digit',
  116. }),
  117. })}
  118. </>
  119. )}
  120. </span>
  121. ) : (
  122. <span className="text-xs text-muted-foreground">{t('sidebar.sharedLink.notViewed')}</span>
  123. )}
  124. </div>
  125. </div>
  126. )
  127. }