From ce18390a2494fb8efaae08594ea795f3b5057ec9 Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Wed, 4 Mar 2026 22:33:00 +0100 Subject: [PATCH 01/36] beautify QR code content preview hover cards for all types Add hover cards for WiFi (SSID, masked password, encryption badge), EPC/Bank (IBAN, BIC, amount, purpose), and Location (address, coordinates). Redesign existing vCard, Event, and Email cards with consistent style: colored icon header, structured label-value rows. Show vCard hover card for static QR codes too. Remove redundant inline icons from trigger text. Co-Authored-By: Claude Opus 4.6 --- .../content-renderers/EmailDetailsCard.tsx | 39 ++++++++-- .../content-renderers/EpcDetailsCard.tsx | 68 +++++++++++++++++ .../content-renderers/EventDetailsCard.tsx | 52 ++++++++----- .../content-renderers/LocationDetailsCard.tsx | 54 ++++++++++++++ .../content-renderers/RenderContent.tsx | 24 ++---- .../content-renderers/VCardDetailsCard.tsx | 74 ++++++++++++------- .../content-renderers/WifiDetailsCard.tsx | 56 ++++++++++++++ 7 files changed, 300 insertions(+), 67 deletions(-) create mode 100644 apps/frontend/src/components/dashboard/qrCode/content-renderers/EpcDetailsCard.tsx create mode 100644 apps/frontend/src/components/dashboard/qrCode/content-renderers/LocationDetailsCard.tsx create mode 100644 apps/frontend/src/components/dashboard/qrCode/content-renderers/WifiDetailsCard.tsx diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/EmailDetailsCard.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/EmailDetailsCard.tsx index c43ea887..a65caf49 100644 --- a/apps/frontend/src/components/dashboard/qrCode/content-renderers/EmailDetailsCard.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/EmailDetailsCard.tsx @@ -1,5 +1,6 @@ 'use client'; +import { EnvelopeIcon } from '@heroicons/react/24/outline'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { Separator } from '@/components/ui/separator'; import type { TEmailInput } from '@shared/schemas'; @@ -11,13 +12,37 @@ interface EmailDetailsCardProps { export const EmailDetailsCard = ({ email }: EmailDetailsCardProps) => { return ( - {email.email} - -
-

{email.email}

- - {email.subject &&

{email.subject}

} - {email.body &&

{email.body}

} + {email.email} + +
+
+
+ +
+
+

{email.email}

+

Email

+
+
+ {(email.subject || email.body) && ( + <> + +
+ {email.subject && ( +
+ Subject + {email.subject} +
+ )} + {email.body && ( +
+ Body +

{email.body}

+
+ )} +
+ + )}
diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/EpcDetailsCard.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/EpcDetailsCard.tsx new file mode 100644 index 00000000..aa3a293e --- /dev/null +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/EpcDetailsCard.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { BanknotesIcon } from '@heroicons/react/24/outline'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; +import { Separator } from '@/components/ui/separator'; +import type { TEpcInput } from '@shared/schemas'; + +interface EpcDetailsCardProps { + epc: TEpcInput; +} + +const formatIban = (iban: string) => { + return iban + .replace(/\s/g, '') + .replace(/(.{4})/g, '$1 ') + .trim(); +}; + +export const EpcDetailsCard = ({ epc }: EpcDetailsCardProps) => { + return ( + + {epc.name} + +
+
+
+ +
+
+

{epc.name}

+

Bank Transfer (EPC)

+
+
+ +
+
+ IBAN + {formatIban(epc.iban)} +
+ {epc.bic && ( +
+ BIC + {epc.bic} +
+ )} + {epc.amount && ( +
+ Amount + + {new Intl.NumberFormat('de-DE', { + style: 'currency', + currency: 'EUR', + }).format(epc.amount)} + +
+ )} + {epc.purpose && ( + <> + +

{epc.purpose}

+ + )} +
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/EventDetailsCard.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/EventDetailsCard.tsx index 94117133..f44ba375 100644 --- a/apps/frontend/src/components/dashboard/qrCode/content-renderers/EventDetailsCard.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/EventDetailsCard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { CalendarIcon, MapPinIcon } from '@heroicons/react/24/outline'; +import { CalendarIcon, MapPinIcon, LinkIcon } from '@heroicons/react/24/outline'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { Separator } from '@/components/ui/separator'; import { formatDate } from '@/lib/utils'; @@ -14,25 +14,41 @@ interface EventDetailsCardProps { export const EventDetailsCard = ({ event, trigger }: EventDetailsCardProps) => { return ( - {trigger} - -
-

{event.title}

- {event.description &&

{event.description}

} - - {event.location && ( -
- - {event.location} + {trigger} + +
+
+
+ +
+
+

{event.title}

+

Event

- )} -
- - {formatDate(event.startDate)}
-
- - {formatDate(event.endDate)} + +
+ {event.description && ( +

{event.description}

+ )} +
+ + + {formatDate(event.startDate)} – {formatDate(event.endDate)} + +
+ {event.location && ( +
+ + {event.location} +
+ )} + {event.url && ( +
+ + {event.url} +
+ )}
diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/LocationDetailsCard.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/LocationDetailsCard.tsx new file mode 100644 index 00000000..930426aa --- /dev/null +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/LocationDetailsCard.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { MapPinIcon } from '@heroicons/react/24/outline'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; +import { Separator } from '@/components/ui/separator'; +import type { TLocationInput } from '@shared/schemas'; + +interface LocationDetailsCardProps { + location: TLocationInput; +} + +const formatCoordinate = (value: number, isLatitude: boolean) => { + const direction = isLatitude ? (value >= 0 ? 'N' : 'S') : value >= 0 ? 'E' : 'W'; + return `${Math.abs(value).toFixed(6)}° ${direction}`; +}; + +export const LocationDetailsCard = ({ location }: LocationDetailsCardProps) => { + const hasCoordinates = location.latitude !== undefined && location.longitude !== undefined; + + return ( + + {location.address} + +
+
+
+ +
+
+

Location

+

Geographic coordinates

+
+
+ +
+

{location.address}

+ {hasCoordinates && ( +
+
+ Latitude + {formatCoordinate(location.latitude!, true)} +
+
+ Longitude + {formatCoordinate(location.longitude!, false)} +
+
+ )} +
+
+
+
+ ); +}; diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx index 32124730..76b95849 100644 --- a/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx @@ -7,6 +7,9 @@ import { EventDetailsCard } from './EventDetailsCard'; import { ShortUrlDisplay } from './ShortUrlDisplay'; import { EmailDetailsCard } from './EmailDetailsCard'; import { VCardDetailsCard } from './VCardDetailsCard'; +import { WifiDetailsCard } from './WifiDetailsCard'; +import { EpcDetailsCard } from './EpcDetailsCard'; +import { LocationDetailsCard } from './LocationDetailsCard'; const renderUrlContent = (qr: TQrCodeWithRelationsResponseDto) => { if (qr.content.type !== 'url') return null; @@ -56,7 +59,6 @@ const renderVCardContent = (qr: TQrCodeWithRelationsResponseDto) => { const { firstName = '', lastName = '', isDynamic } = vcardData; const displayName = `${firstName} ${lastName}`.trim() || 'Contact'; - // If dynamic and has short URL, show with ShortUrlDisplay if (isDynamic && qr.shortUrl) { return ( { ); } - // Static vCard or no short URL - show basic name - return displayName; + return ; }; export const RenderContent = memo(({ qr }: { qr: TQrCodeWithRelationsResponseDto }) => { @@ -78,26 +79,17 @@ export const RenderContent = memo(({ qr }: { qr: TQrCodeWithRelationsResponseDto case 'text': return qr.content.data; case 'wifi': - return qr.content.data?.ssid || ''; + return ; case 'vCard': return renderVCardContent(qr); case 'email': return ; case 'location': - return qr.content.data.address || ''; + return ; case 'event': return renderEventContent(qr); - case 'epc': { - const { name, iban, amount } = qr.content.data; - const formattedAmount = amount ? `€${amount.toFixed(2)}` : ''; - const ibanLine = formattedAmount ? `${iban} · ${formattedAmount}` : iban; - return ( -
- {name} - {ibanLine} -
- ); - } + case 'epc': + return ; default: return 'Unknown'; } diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/VCardDetailsCard.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/VCardDetailsCard.tsx index 45211bcd..d9e7e25c 100644 --- a/apps/frontend/src/components/dashboard/qrCode/content-renderers/VCardDetailsCard.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/VCardDetailsCard.tsx @@ -1,6 +1,13 @@ 'use client'; -import { EnvelopeIcon, PhoneIcon, BuildingOfficeIcon } from '@heroicons/react/24/outline'; +import { + EnvelopeIcon, + PhoneIcon, + BuildingOfficeIcon, + GlobeAltIcon, + MapPinIcon, + UserIcon, +} from '@heroicons/react/24/outline'; import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; import { Separator } from '@/components/ui/separator'; import type { TVCardInput } from '@shared/schemas'; @@ -12,36 +19,51 @@ interface VCardDetailsCardProps { export const VCardDetailsCard = ({ vcard, trigger }: VCardDetailsCardProps) => { const fullName = `${vcard.firstName || ''} ${vcard.lastName || ''}`.trim() || 'Contact'; + const email = vcard.emailPrivate || vcard.emailBusiness || vcard.email; + const phone = vcard.phoneMobile || vcard.phone || vcard.phonePrivate || vcard.phoneBusiness; + const addressParts = [vcard.street, vcard.zip, vcard.city, vcard.state, vcard.country].filter( + Boolean, + ); + const address = addressParts.length > 0 ? addressParts.join(', ') : null; + + const rows: { icon: React.ElementType; value: string }[] = []; + if (vcard.job || vcard.company) { + const parts = [vcard.job, vcard.company].filter(Boolean); + rows.push({ icon: BuildingOfficeIcon, value: parts.join(' at ') }); + } + if (email) rows.push({ icon: EnvelopeIcon, value: email }); + if (phone) rows.push({ icon: PhoneIcon, value: phone }); + if (vcard.website) rows.push({ icon: GlobeAltIcon, value: vcard.website }); + if (address) rows.push({ icon: MapPinIcon, value: address }); return ( - {trigger} - -
-

{fullName}

- {vcard.job &&

{vcard.job}

} - - {(vcard.emailPrivate || vcard.emailBusiness || vcard.email) && ( -
- - - {vcard.emailPrivate || vcard.emailBusiness || vcard.email} - + {trigger} + +
+
+
+
- )} - {(vcard.phoneMobile || vcard.phone || vcard.phonePrivate || vcard.phoneBusiness) && ( -
- - - {vcard.phoneMobile || vcard.phone || vcard.phonePrivate || vcard.phoneBusiness} - -
- )} - {vcard.company && ( -
- - {vcard.company} +
+

{fullName}

+ {vcard.title && ( +

{vcard.title}

+ )}
+
+ {rows.length > 0 && ( + <> + +
+ {rows.map(({ icon: Icon, value }) => ( +
+ + {value} +
+ ))} +
+ )}
diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/WifiDetailsCard.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/WifiDetailsCard.tsx new file mode 100644 index 00000000..2dcdcbad --- /dev/null +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/WifiDetailsCard.tsx @@ -0,0 +1,56 @@ +'use client'; + +import { WifiIcon, LockClosedIcon, LockOpenIcon } from '@heroicons/react/24/outline'; +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'; +import { Separator } from '@/components/ui/separator'; +import { Badge } from '@/components/ui/badge'; +import type { TWifiInput } from '@shared/schemas'; + +interface WifiDetailsCardProps { + wifi: TWifiInput; +} + +export const WifiDetailsCard = ({ wifi }: WifiDetailsCardProps) => { + const encryptionLabel = wifi.encryption === 'nopass' ? 'Open' : wifi.encryption.toUpperCase(); + + return ( + + {wifi.ssid} + +
+
+
+ +
+
+

{wifi.ssid}

+

Wi-Fi Network

+
+
+ +
+ {wifi.password && ( +
+ Password + + {'*'.repeat(Math.min(wifi.password.length, 12))} + +
+ )} +
+ Security + + {wifi.encryption === 'nopass' ? ( + + ) : ( + + )} + {encryptionLabel} + +
+
+
+
+
+ ); +}; From 2499cf8fb533e71f68a8090a774ddaf815cfb47a Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Wed, 4 Mar 2026 22:42:01 +0100 Subject: [PATCH 02/36] add detailed analytics link to scan tooltip and update translations Co-Authored-By: Claude Opus 4.6 --- .../components/dashboard/qrCode/ListItem.tsx | 17 +++++++++++++---- apps/frontend/src/dictionaries/de.json | 6 ++++-- apps/frontend/src/dictionaries/en.json | 4 +++- apps/frontend/src/dictionaries/es.json | 6 ++++-- apps/frontend/src/dictionaries/fr.json | 6 ++++-- apps/frontend/src/dictionaries/it.json | 6 ++++-- apps/frontend/src/dictionaries/nl.json | 6 ++++-- apps/frontend/src/dictionaries/pl.json | 6 ++++-- apps/frontend/src/dictionaries/ru.json | 6 ++++-- 9 files changed, 44 insertions(+), 19 deletions(-) diff --git a/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx b/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx index 9a586a23..ab464d07 100644 --- a/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx @@ -2,6 +2,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { EyeIcon, Loader2 } from 'lucide-react'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; import { TableCell, TableRow } from '@/components/ui/table'; @@ -42,7 +43,7 @@ const contextMenuComponents: MenuComponents = { const stopContextMenu = (e: React.MouseEvent) => e.stopPropagation(); -const ViewComponent = ({ shortUrl }: { shortUrl: TShortUrl }) => { +const ViewComponent = ({ shortUrl, qrCodeId }: { shortUrl: TShortUrl; qrCodeId: string }) => { const t = useTranslations(); const { data, isLoading } = useGetViewsFromShortCodeQuery(shortUrl.shortCode); @@ -65,8 +66,16 @@ const ViewComponent = ({ shortUrl }: { shortUrl: TShortUrl }) => { {data.views}
- - {data.views} {t('analytics.totalViews')} + + + {data.views} {t('analytics.scansUnit')} + + + {t('analytics.viewDetailedAnalytics')} + ); @@ -168,7 +177,7 @@ export const QrCodeListItem = ({ {/* Scans */} {visibility.scans && ( - {qr.shortUrl && } + {qr.shortUrl && } )} diff --git a/apps/frontend/src/dictionaries/de.json b/apps/frontend/src/dictionaries/de.json index 0123ef33..99fda773 100644 --- a/apps/frontend/src/dictionaries/de.json +++ b/apps/frontend/src/dictionaries/de.json @@ -94,7 +94,9 @@ } }, "analytics": { - "totalViews": "Anzahl der Scans", + "totalViews": "Scans gesamt", + "scansUnit": "Scans", + "viewDetailedAnalytics": "Detaillierte Analysen anzeigen", "totalVisitors": "Anzahl der Besucher", "stateActive": "Aktiv", "stateInactive": "Deaktiviert", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Aktionen", - "view": "Anzeigen", + "view": "Details anzeigen", "edit": "Bearbeiten", "delete": "Löschen", "disableShortUrl": "Deaktivieren", diff --git a/apps/frontend/src/dictionaries/en.json b/apps/frontend/src/dictionaries/en.json index fb65a24b..d4f342eb 100644 --- a/apps/frontend/src/dictionaries/en.json +++ b/apps/frontend/src/dictionaries/en.json @@ -95,6 +95,8 @@ }, "analytics": { "totalViews": "Total Scans", + "scansUnit": "Scans", + "viewDetailedAnalytics": "View detailed analytics", "totalVisitors": "Total Visitors", "stateActive": "Active", "stateInactive": "Disabled", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Actions", - "view": "View", + "view": "Show Details", "edit": "Edit", "delete": "Delete", "disableShortUrl": "Disable", diff --git a/apps/frontend/src/dictionaries/es.json b/apps/frontend/src/dictionaries/es.json index 34ae5163..9a172175 100644 --- a/apps/frontend/src/dictionaries/es.json +++ b/apps/frontend/src/dictionaries/es.json @@ -94,7 +94,9 @@ } }, "analytics": { - "totalViews": "Número de escaneos", + "totalViews": "Escaneos totales", + "scansUnit": "Escaneos", + "viewDetailedAnalytics": "Ver análisis detallados", "totalVisitors": "Número de visitantes", "stateActive": "Activo", "stateInactive": "Desactivado", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Acciones", - "view": "Ver", + "view": "Ver detalles", "edit": "Editar", "delete": "Eliminar", "disableShortUrl": "Desactivar", diff --git a/apps/frontend/src/dictionaries/fr.json b/apps/frontend/src/dictionaries/fr.json index 3f340d19..25097432 100644 --- a/apps/frontend/src/dictionaries/fr.json +++ b/apps/frontend/src/dictionaries/fr.json @@ -94,7 +94,9 @@ } }, "analytics": { - "totalViews": "Nombre de scans", + "totalViews": "Scans totaux", + "scansUnit": "Scans", + "viewDetailedAnalytics": "Voir les analyses détaillées", "totalVisitors": "Nombre de visiteurs", "stateActive": "Actif", "stateInactive": "Désactivé", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Actions", - "view": "Voir", + "view": "Voir les détails", "edit": "Modifier", "delete": "Supprimer", "disableShortUrl": "Désactiver", diff --git a/apps/frontend/src/dictionaries/it.json b/apps/frontend/src/dictionaries/it.json index 8e00f39a..3fe3a612 100644 --- a/apps/frontend/src/dictionaries/it.json +++ b/apps/frontend/src/dictionaries/it.json @@ -94,7 +94,9 @@ } }, "analytics": { - "totalViews": "Numero di scansioni", + "totalViews": "Scansioni totali", + "scansUnit": "Scansioni", + "viewDetailedAnalytics": "Visualizza analisi dettagliate", "totalVisitors": "Numero di visitatori", "stateActive": "Attivo", "stateInactive": "Disattivato", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Azioni", - "view": "Visualizza", + "view": "Mostra dettagli", "edit": "Modifica", "delete": "Elimina", "disableShortUrl": "Disabilita", diff --git a/apps/frontend/src/dictionaries/nl.json b/apps/frontend/src/dictionaries/nl.json index e55dbcaf..85991807 100644 --- a/apps/frontend/src/dictionaries/nl.json +++ b/apps/frontend/src/dictionaries/nl.json @@ -94,7 +94,9 @@ } }, "analytics": { - "totalViews": "Aantal scans", + "totalViews": "Totaal scans", + "scansUnit": "Scans", + "viewDetailedAnalytics": "Bekijk gedetailleerde analyses", "totalVisitors": "Aantal bezoekers", "stateActive": "Actief", "stateInactive": "Uitgeschakeld", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Acties", - "view": "Bekijken", + "view": "Details bekijken", "edit": "Bewerken", "delete": "Verwijderen", "disableShortUrl": "Uitschakelen", diff --git a/apps/frontend/src/dictionaries/pl.json b/apps/frontend/src/dictionaries/pl.json index 813681af..7c854bd8 100644 --- a/apps/frontend/src/dictionaries/pl.json +++ b/apps/frontend/src/dictionaries/pl.json @@ -94,7 +94,9 @@ } }, "analytics": { - "totalViews": "Liczba skanów", + "totalViews": "Łączna liczba skanów", + "scansUnit": "Skanów", + "viewDetailedAnalytics": "Zobacz szczegółowe analizy", "totalVisitors": "Liczba odwiedzających", "stateActive": "Aktywny", "stateInactive": "Wyłączony", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Akcje", - "view": "Wyświetl", + "view": "Pokaż szczegóły", "edit": "Edytuj", "delete": "Usuń", "disableShortUrl": "Wyłącz", diff --git a/apps/frontend/src/dictionaries/ru.json b/apps/frontend/src/dictionaries/ru.json index c44486ae..6bdf9686 100644 --- a/apps/frontend/src/dictionaries/ru.json +++ b/apps/frontend/src/dictionaries/ru.json @@ -94,7 +94,9 @@ } }, "analytics": { - "totalViews": "Количество сканирований", + "totalViews": "Всего сканирований", + "scansUnit": "Сканирований", + "viewDetailedAnalytics": "Посмотреть подробную аналитику", "totalVisitors": "Количество посетителей", "stateActive": "Активно", "stateInactive": "Отключено", @@ -264,7 +266,7 @@ }, "actionsMenu": { "title": "Действия", - "view": "Просмотр", + "view": "Показать детали", "edit": "Редактировать", "delete": "Удалить", "disableShortUrl": "Отключить", From 12f7ce4faa5fbc7329ab6c9a2e259d60022f6ab4 Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Wed, 4 Mar 2026 23:06:26 +0100 Subject: [PATCH 03/36] improve QR code list UX: copy buttons, truncation tooltips, and styling - Add copy-to-clipboard button on hover for URLs in content column (short URLs, destination URLs, static URLs) - Add truncation-aware tooltips for names, URLs, and tags (only shown when text is actually cropped) - Constrain name column width and use text-foreground for content column - Fix dynamic URL display: proper truncation, arrow icon visibility, and min-width constraints - Add useIsTruncated hook and TruncatedLink/CopyUrlButton reusable components - Update translations for copy URL feature across all 8 locales Co-Authored-By: Claude Opus 4.6 --- .../components/dashboard/qrCode/ListItem.tsx | 2 +- .../dashboard/qrCode/QrCodeNameCell.tsx | 27 ++++-- .../dashboard/qrCode/QrCodeTagBadges.tsx | 89 ++++++++++++------- .../content-renderers/CopyUrlButton.tsx | 47 ++++++++++ .../content-renderers/RenderContent.tsx | 17 ++-- .../content-renderers/ShortUrlDisplay.tsx | 41 ++++----- .../content-renderers/TruncatedLink.tsx | 36 ++++++++ apps/frontend/src/dictionaries/de.json | 4 +- apps/frontend/src/dictionaries/en.json | 4 +- apps/frontend/src/dictionaries/es.json | 4 +- apps/frontend/src/dictionaries/fr.json | 4 +- apps/frontend/src/dictionaries/it.json | 4 +- apps/frontend/src/dictionaries/nl.json | 4 +- apps/frontend/src/dictionaries/pl.json | 4 +- apps/frontend/src/dictionaries/ru.json | 4 +- apps/frontend/src/hooks/use-is-truncated.ts | 23 +++++ 16 files changed, 228 insertions(+), 86 deletions(-) create mode 100644 apps/frontend/src/components/dashboard/qrCode/content-renderers/CopyUrlButton.tsx create mode 100644 apps/frontend/src/components/dashboard/qrCode/content-renderers/TruncatedLink.tsx create mode 100644 apps/frontend/src/hooks/use-is-truncated.ts diff --git a/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx b/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx index ab464d07..9b73b2c6 100644 --- a/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/ListItem.tsx @@ -143,7 +143,7 @@ export const QrCodeListItem = ({ {/* Content */} {visibility.content && ( -
+
diff --git a/apps/frontend/src/components/dashboard/qrCode/QrCodeNameCell.tsx b/apps/frontend/src/components/dashboard/qrCode/QrCodeNameCell.tsx index 7bd87b87..ad359853 100644 --- a/apps/frontend/src/components/dashboard/qrCode/QrCodeNameCell.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/QrCodeNameCell.tsx @@ -5,6 +5,7 @@ import { TableCell } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import type { TQrCodeWithRelationsResponseDto } from '@shared/schemas'; +import { useIsTruncated } from '@/hooks/use-is-truncated'; import { QrCodeIcon } from './QrCodeIcon'; import { QrCodeTagBadges } from './QrCodeTagBadges'; import { QrCodeTagSelector } from './QrCodeTagSelector'; @@ -27,9 +28,10 @@ export const QrCodeNameCell = ({ }: QrCodeNameCellProps) => { const t = useTranslations(); const hasTags = qr.tags ?? []; + const [nameRef, isNameTruncated, checkNameTruncation] = useIsTruncated(); return ( - +
@@ -44,13 +46,22 @@ export const QrCodeNameCell = ({ onContextMenu={stopContextMenu} >
- - {qr.name && qr.name !== '' ? ( - qr.name - ) : ( - {t('general.noName')} - )} - + + + + {qr.name && qr.name !== '' ? ( + qr.name + ) : ( + {t('general.noName')} + )} + + + {qr.name} + + + + {tag.name} + + ); +}; + type QrCodeTagBadgesProps = { qrCodeId: string; tags: TTagResponseDto[]; @@ -18,43 +59,23 @@ export const QrCodeTagBadges = ({ qrCodeId, tags: initialTags }: QrCodeTagBadges const [tags, setTags] = useState(initialTags); const setTagsMutation = useSetQrCodeTagsMutation(); + const handleRemove = async (tagId: string) => { + const updatedTagIds = tags.filter((t) => t.id !== tagId).map((t) => t.id); + try { + const updatedTags = await setTagsMutation.mutateAsync({ + qrCodeId, + tagIds: updatedTagIds, + }); + setTags(updatedTags); + } catch { + toast({ title: 'Failed to remove tag', variant: 'destructive' }); + } + }; + return (
{tags.map((tag) => ( - - - - - {tag.name} - - - - {tag.name} - + ))} { + const t = useTranslations(); + const [copied, setCopied] = useState(false); + + const handleCopy = async (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + try { + await navigator.clipboard.writeText(url); + setCopied(true); + toast({ + title: t('qrCode.table.urlCopied'), + }); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API not available + } + }; + + return ( + + + + + {t('qrCode.table.copyUrl')} + + ); +}; diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx index 76b95849..2a3da5a1 100644 --- a/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/RenderContent.tsx @@ -1,7 +1,6 @@ 'use client'; import { memo } from 'react'; -import Link from 'next/link'; import type { TQrCodeWithRelationsResponseDto } from '@shared/schemas'; import { EventDetailsCard } from './EventDetailsCard'; import { ShortUrlDisplay } from './ShortUrlDisplay'; @@ -10,6 +9,8 @@ import { VCardDetailsCard } from './VCardDetailsCard'; import { WifiDetailsCard } from './WifiDetailsCard'; import { EpcDetailsCard } from './EpcDetailsCard'; import { LocationDetailsCard } from './LocationDetailsCard'; +import { CopyUrlButton } from './CopyUrlButton'; +import { TruncatedLink } from './TruncatedLink'; const renderUrlContent = (qr: TQrCodeWithRelationsResponseDto) => { if (qr.content.type !== 'url') return null; @@ -21,16 +22,10 @@ const renderUrlContent = (qr: TQrCodeWithRelationsResponseDto) => { } return ( - e.stopPropagation()} - onContextMenu={(e) => e.stopPropagation()} - className="text-muted-foreground hover:underline" - > - {url} - +
+ + +
); }; diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/ShortUrlDisplay.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/ShortUrlDisplay.tsx index 67e950d3..062060d2 100644 --- a/apps/frontend/src/components/dashboard/qrCode/content-renderers/ShortUrlDisplay.tsx +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/ShortUrlDisplay.tsx @@ -1,9 +1,11 @@ 'use client'; import { ArrowTurnDownRightIcon } from '@heroicons/react/24/outline'; -import Link from 'next/link'; import { useShortUrlLink } from '@/hooks/use-short-url-link'; +import { cn } from '@/lib/utils'; import type { TShortUrlResponseDto } from '@shared/schemas'; +import { CopyUrlButton } from './CopyUrlButton'; +import { TruncatedLink } from './TruncatedLink'; interface ShortUrlDisplayProps { shortUrl: TShortUrlResponseDto; @@ -25,31 +27,22 @@ export const ShortUrlDisplay = ({ } return ( -
- e.stopPropagation()} - onContextMenu={(e) => e.stopPropagation()} - className={className} - > - {shortUrl} - +
+
+ + +
{destinationUrl && ( -
- +
+ {destinationContent || ( - e.stopPropagation()} - onContextMenu={(e) => e.stopPropagation()} - href={destinationUrl} - target="_blank" - className="pt-1 text-sm text-black hover:underline" - prefetch={false} - > - {destinationUrl} - +
+ + +
)}
)} diff --git a/apps/frontend/src/components/dashboard/qrCode/content-renderers/TruncatedLink.tsx b/apps/frontend/src/components/dashboard/qrCode/content-renderers/TruncatedLink.tsx new file mode 100644 index 00000000..6a972f54 --- /dev/null +++ b/apps/frontend/src/components/dashboard/qrCode/content-renderers/TruncatedLink.tsx @@ -0,0 +1,36 @@ +'use client'; + +import Link from 'next/link'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { useIsTruncated } from '@/hooks/use-is-truncated'; + +interface TruncatedLinkProps { + href: string; + className?: string; +} + +export const TruncatedLink = ({ href, className }: TruncatedLinkProps) => { + const [ref, isTruncated, checkTruncation] = useIsTruncated(); + + return ( + + + e.stopPropagation()} + onContextMenu={(e) => e.stopPropagation()} + className={className} + > + {href} + + + + {href} + + + ); +}; diff --git a/apps/frontend/src/dictionaries/de.json b/apps/frontend/src/dictionaries/de.json index 99fda773..235e5a03 100644 --- a/apps/frontend/src/dictionaries/de.json +++ b/apps/frontend/src/dictionaries/de.json @@ -221,7 +221,9 @@ "scans": "Scans", "created": "Erstellt", "actions": "Aktionen", - "tags": "Tags" + "tags": "Tags", + "copyUrl": "URL kopieren", + "urlCopied": "URL in die Zwischenablage kopiert" }, "storeBtn": "In Sammlung speichern", "updateBtn": "QR Code aktualisieren", diff --git a/apps/frontend/src/dictionaries/en.json b/apps/frontend/src/dictionaries/en.json index d4f342eb..fdfb2fae 100644 --- a/apps/frontend/src/dictionaries/en.json +++ b/apps/frontend/src/dictionaries/en.json @@ -221,7 +221,9 @@ "scans": "Scans", "created": "Created", "actions": "Actions", - "tags": "Tags" + "tags": "Tags", + "copyUrl": "Copy URL", + "urlCopied": "URL copied to clipboard" }, "storeBtn": "Save in Collection", "updateBtn": "Update QR code", diff --git a/apps/frontend/src/dictionaries/es.json b/apps/frontend/src/dictionaries/es.json index 9a172175..9a4dff53 100644 --- a/apps/frontend/src/dictionaries/es.json +++ b/apps/frontend/src/dictionaries/es.json @@ -221,7 +221,9 @@ "scans": "Escaneos", "created": "Creado", "actions": "Acciones", - "tags": "Etiquetas" + "tags": "Etiquetas", + "copyUrl": "Copiar URL", + "urlCopied": "URL copiada al portapapeles" }, "storeBtn": "Guardar en la colección", "updateBtn": "Actualizar código QR", diff --git a/apps/frontend/src/dictionaries/fr.json b/apps/frontend/src/dictionaries/fr.json index 25097432..4c677d79 100644 --- a/apps/frontend/src/dictionaries/fr.json +++ b/apps/frontend/src/dictionaries/fr.json @@ -221,7 +221,9 @@ "scans": "Scans", "created": "Créé", "actions": "Actions", - "tags": "Étiquettes" + "tags": "Étiquettes", + "copyUrl": "Copier l'URL", + "urlCopied": "URL copiée dans le presse-papiers" }, "storeBtn": "Enregistrer dans la collection", "updateBtn": "Mettre à jour le QR Code", diff --git a/apps/frontend/src/dictionaries/it.json b/apps/frontend/src/dictionaries/it.json index 3fe3a612..e3117fdb 100644 --- a/apps/frontend/src/dictionaries/it.json +++ b/apps/frontend/src/dictionaries/it.json @@ -221,7 +221,9 @@ "scans": "Scansioni", "created": "Creato", "actions": "Azioni", - "tags": "Tag" + "tags": "Tag", + "copyUrl": "Copia URL", + "urlCopied": "URL copiato negli appunti" }, "storeBtn": "Salva nella collezione", "updateBtn": "Aggiorna QR Code", diff --git a/apps/frontend/src/dictionaries/nl.json b/apps/frontend/src/dictionaries/nl.json index 85991807..1abd5e8e 100644 --- a/apps/frontend/src/dictionaries/nl.json +++ b/apps/frontend/src/dictionaries/nl.json @@ -221,7 +221,9 @@ "scans": "Scans", "created": "Aangemaakt", "actions": "Acties", - "tags": "Tags" + "tags": "Tags", + "copyUrl": "URL kopiëren", + "urlCopied": "URL gekopieerd naar klembord" }, "storeBtn": "Opslaan in collectie", "updateBtn": "QR-code bijwerken", diff --git a/apps/frontend/src/dictionaries/pl.json b/apps/frontend/src/dictionaries/pl.json index 7c854bd8..036e2714 100644 --- a/apps/frontend/src/dictionaries/pl.json +++ b/apps/frontend/src/dictionaries/pl.json @@ -221,7 +221,9 @@ "scans": "Skany", "created": "Utworzono", "actions": "Akcje", - "tags": "Tagi" + "tags": "Tagi", + "copyUrl": "Kopiuj URL", + "urlCopied": "URL skopiowany do schowka" }, "storeBtn": "Zapisz w kolekcji", "updateBtn": "Zaktualizuj kod QR", diff --git a/apps/frontend/src/dictionaries/ru.json b/apps/frontend/src/dictionaries/ru.json index 6bdf9686..cd94a40a 100644 --- a/apps/frontend/src/dictionaries/ru.json +++ b/apps/frontend/src/dictionaries/ru.json @@ -221,7 +221,9 @@ "scans": "Сканирования", "created": "Создано", "actions": "Действия", - "tags": "Теги" + "tags": "Теги", + "copyUrl": "Копировать URL", + "urlCopied": "URL скопирован в буфер обмена" }, "storeBtn": "Сохранить в коллекцию", "updateBtn": "Обновить QR-код", diff --git a/apps/frontend/src/hooks/use-is-truncated.ts b/apps/frontend/src/hooks/use-is-truncated.ts new file mode 100644 index 00000000..59a56eef --- /dev/null +++ b/apps/frontend/src/hooks/use-is-truncated.ts @@ -0,0 +1,23 @@ +import { useRef, useState, useCallback, type RefObject } from 'react'; + +/** + * Detects whether a text element is truncated (has overflow). + * Returns a ref to attach to the element and a boolean indicating truncation. + * Uses onMouseEnter callback to check only on hover, avoiding layout thrashing. + */ +export function useIsTruncated(): [ + RefObject, + boolean, + () => void, +] { + const ref = useRef(null); + const [isTruncated, setIsTruncated] = useState(false); + + const checkTruncation = useCallback(() => { + if (ref.current) { + setIsTruncated(ref.current.scrollWidth > ref.current.clientWidth); + } + }, []); + + return [ref, isTruncated, checkTruncation]; +} From c92b17be4a0d5472b99940e28a5f36905ba6f6a9 Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Thu, 5 Mar 2026 10:34:28 +0100 Subject: [PATCH 04/36] improve QR code edit page: show content type icon in name field and fix mobile layout - Hide redundant content type select dropdown in edit mode - Add content type icon (link, wifi, etc.) inside the name input field - Remove inner padding on mobile for compact/edit mode to prevent overflow - Truncate short URL text on narrow screens - Add min-w-0 to flex child for proper shrinking Co-Authored-By: Claude Opus 4.6 --- .../components/qr-generator/QRcodeGenerator.tsx | 15 +++++++++++---- .../qr-generator/content/ContentSwitch.tsx | 4 ++-- .../qr-generator/content/EditUrlSection.tsx | 6 +++--- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/frontend/src/components/qr-generator/QRcodeGenerator.tsx b/apps/frontend/src/components/qr-generator/QRcodeGenerator.tsx index 9ddaa44b..0d287159 100644 --- a/apps/frontend/src/components/qr-generator/QRcodeGenerator.tsx +++ b/apps/frontend/src/components/qr-generator/QRcodeGenerator.tsx @@ -16,6 +16,7 @@ import type { TQrCodeContentType } from '@shared/schemas'; import { InputGroup, InputGroupAddon, InputGroupInput } from '../ui/input-group'; import { CharacterCounter } from './content/CharacterCounter'; import { cn } from '@/lib/utils'; +import { getContentTypeConfig } from '@/lib/content-type.config'; type GeneratorTab = 'content' | 'style' | 'templates'; type GeneratorTabConfig = { @@ -71,7 +72,7 @@ export const QRcodeGenerator = ({ generatorType, }: QRcodeGeneratorProps) => { const t = useTranslations('generator'); - const { name, updateName } = useQrCodeGeneratorStore((state) => state); + const { name, updateName, content } = useQrCodeGeneratorStore((state) => state); const [currentTab, setCurrentTab] = useState('content'); const visibleTabs = useMemo( @@ -106,6 +107,7 @@ export const QRcodeGenerator = ({ [updateName], ); const QrOutputComponent = QR_OUTPUT_MAP[generatorType]; + const ContentTypeIcon = useMemo(() => getContentTypeConfig(content.type)?.icon, [content.type]); return ( @@ -132,15 +134,20 @@ export const QRcodeGenerator = ({ {!compact && backLink}
-
+
{isEditMode && currentTab === 'content' && (
{t('labelName')}
+ {ContentTypeIcon && ( + + + + )} - {/* Compact mode: select dropdown. Normal mode: tab grid */} - {compact ? ( + {/* Compact mode in edit: hide selector (type is fixed). Compact mode otherwise: select dropdown. Normal mode: tab grid */} + {compact && isEditMode ? null : compact ? (
+ + + + )} + /> + ( + + )} + /> +
+ +
+ + + + + ); +} diff --git a/apps/frontend/src/components/dashboard/shortUrl/DeleteShortUrlDialog.tsx b/apps/frontend/src/components/dashboard/shortUrl/DeleteShortUrlDialog.tsx new file mode 100644 index 00000000..6fa795fc --- /dev/null +++ b/apps/frontend/src/components/dashboard/shortUrl/DeleteShortUrlDialog.tsx @@ -0,0 +1,54 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { + AlertDialog, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; +import { Button } from '@/components/ui/button'; + +interface DeleteShortUrlDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onConfirm: () => void; + isDeleting: boolean; +} + +export function DeleteShortUrlDialog({ + open, + onOpenChange, + onConfirm, + isDeleting, +}: DeleteShortUrlDialogProps) { + const t = useTranslations('shortUrl'); + const tGeneral = useTranslations('general'); + + return ( + + + + {t('delete.title')} + {t('delete.confirm')} + + + + + + + + + + ); +} diff --git a/apps/frontend/src/components/dashboard/shortUrl/EditShortUrlDialog.tsx b/apps/frontend/src/components/dashboard/shortUrl/EditShortUrlDialog.tsx new file mode 100644 index 00000000..ef3457b6 --- /dev/null +++ b/apps/frontend/src/components/dashboard/shortUrl/EditShortUrlDialog.tsx @@ -0,0 +1,138 @@ +'use client'; + +import { useEffect } from 'react'; +import { useTranslations } from 'next-intl'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod/v3'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { toast } from '@/components/ui/use-toast'; +import { useUpdateShortUrlMutation } from '@/lib/api/url-shortener'; +import { DomainSelector } from '@/components/qr-generator/content/DomainSelector'; +import type { TShortUrlWithCustomDomainResponseDto } from '@shared/schemas'; +import posthog from 'posthog-js'; +import * as Sentry from '@sentry/nextjs'; +import type { ApiError } from '@/lib/api/ApiError'; + +const editShortUrlSchema = z.object({ + destinationUrl: z.string().url(), + customDomainId: z.string().nullable(), +}); + +type EditShortUrlForm = z.infer; + +interface EditShortUrlDialogProps { + shortUrl: TShortUrlWithCustomDomainResponseDto; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function EditShortUrlDialog({ shortUrl, open, onOpenChange }: EditShortUrlDialogProps) { + const t = useTranslations('shortUrl'); + const updateMutation = useUpdateShortUrlMutation(); + + const form = useForm({ + resolver: zodResolver(editShortUrlSchema), + defaultValues: { + destinationUrl: shortUrl.destinationUrl ?? '', + customDomainId: shortUrl.customDomain?.id ?? null, + }, + }); + + useEffect(() => { + form.reset({ + destinationUrl: shortUrl.destinationUrl ?? '', + customDomainId: shortUrl.customDomain?.id ?? null, + }); + }, [form, shortUrl]); + + const onSubmit = async (data: EditShortUrlForm) => { + try { + await updateMutation.mutateAsync({ + shortCode: shortUrl.shortCode, + data: { + destinationUrl: data.destinationUrl, + customDomainId: data.customDomainId, + }, + }); + posthog.capture('short-url-updated', { + shortCode: shortUrl.shortCode, + destinationUrl: data.destinationUrl, + }); + toast({ title: t('edit.success') }); + onOpenChange(false); + } catch (e: unknown) { + const error = e as ApiError; + if (error.code >= 500) { + Sentry.captureException(error, { + extra: { + shortCode: shortUrl.shortCode, + error: { code: error.code, message: error.message }, + }, + }); + } + posthog.capture('error:short-url-updated', { + shortCode: shortUrl.shortCode, + error: { code: error.code, message: error.message }, + }); + toast({ + variant: 'destructive', + title: t('error.update.title'), + description: error.message, + }); + } + }; + + return ( + + + + {t('edit.title')} + +
+ + ( + + {t('create.destinationLabel')} + + + + + + )} + /> + ( + + )} + /> +
+ +
+ + +
+
+ ); +} diff --git a/apps/frontend/src/components/dashboard/shortUrl/ShortUrlDetailContent.tsx b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlDetailContent.tsx new file mode 100644 index 00000000..1fd870ea --- /dev/null +++ b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlDetailContent.tsx @@ -0,0 +1,226 @@ +'use client'; + +import React, { useCallback } from 'react'; +import { useTranslations } from 'next-intl'; +import { useLocale } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Card, CardContent } from '@/components/ui/card'; +import { + AlertDialog, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogCancel, +} from '@/components/ui/alert-dialog'; +import { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator, + BreadcrumbPage, +} from '@/components/ui/breadcrumb'; +import { LinkIcon } from '@heroicons/react/24/outline'; +import { toast } from '@/components/ui/use-toast'; +import { CopyUrlButton } from '../qrCode/content-renderers/CopyUrlButton'; +import { AnalyticsSection } from '@/components/qr-code-detail/analytics/AnalyticsSection'; +import { + useDeleteShortUrlMutation, + useToggleActiveStateMutation, + urlShortenerQueryKeys, +} from '@/lib/api/url-shortener'; +import { createLinkFromShortUrl } from '@/lib/utils'; +import { EditShortUrlDialog } from './EditShortUrlDialog'; +import type { TShortUrlWithCustomDomainResponseDto } from '@shared/schemas'; +import Link from 'next/link'; +import type { SupportedLanguages } from '@/i18n/routing'; +import { useQueryClient } from '@tanstack/react-query'; +import posthog from 'posthog-js'; +import * as Sentry from '@sentry/nextjs'; +import type { ApiError } from '@/lib/api/ApiError'; + +interface ShortUrlDetailContentProps { + shortUrl: TShortUrlWithCustomDomainResponseDto; +} + +export function ShortUrlDetailContent({ shortUrl }: ShortUrlDetailContentProps) { + const t = useTranslations(); + const locale = useLocale() as SupportedLanguages; + const router = useRouter(); + const queryClient = useQueryClient(); + const [isDeleting, setIsDeleting] = React.useState(false); + const [editOpen, setEditOpen] = React.useState(false); + + const deleteMutation = useDeleteShortUrlMutation(); + const toggleMutation = useToggleActiveStateMutation(); + + const fullLink = createLinkFromShortUrl(shortUrl); + const displayLink = createLinkFromShortUrl(shortUrl, { short: true }); + + const handleDelete = useCallback(() => { + setIsDeleting(true); + deleteMutation.mutate(shortUrl.shortCode, { + onSuccess: () => { + posthog.capture('short-url-deleted', { + shortCode: shortUrl.shortCode, + }); + router.push(`/${locale}/dashboard/short-urls`); + }, + onError: (e) => { + const error = e as ApiError; + setIsDeleting(false); + if (error.code >= 500) { + Sentry.captureException(error, { + extra: { + shortCode: shortUrl.shortCode, + error: { code: error.code, message: error.message }, + }, + }); + } + posthog.capture('error:short-url-deleted', { + shortCode: shortUrl.shortCode, + error: { code: error.code, message: error.message }, + }); + toast({ + title: t('shortUrl.delete.title'), + variant: 'destructive', + }); + }, + }); + }, [shortUrl.shortCode, deleteMutation, router, locale, t]); + + const handleToggle = () => { + toggleMutation.mutate(shortUrl.shortCode, { + onSuccess: () => { + posthog.capture('short-url-toggled', { + shortCode: shortUrl.shortCode, + isActive: !shortUrl.isActive, + }); + void queryClient.refetchQueries({ + queryKey: urlShortenerQueryKeys.listShortUrls, + }); + router.refresh(); + }, + onError: (error) => { + Sentry.captureException(error); + toast({ + title: t('shortUrl.error.toggleActiveState.title'), + description: t('shortUrl.error.toggleActiveState.message'), + variant: 'destructive', + }); + }, + }); + }; + + return ( + <> + {/* Header Card */} + + + + + + + {t('shortUrl.title')} + + + + + {shortUrl.shortCode} + + + + +
+
+
+ +
+
+

{displayLink}

+ + {shortUrl.isActive ? t('shortUrl.status.active') : t('shortUrl.status.inactive')} + +
+
+
+ + + + + + + + + {t('shortUrl.delete.title')} + {t('shortUrl.delete.confirm')} + + + + + + + + + +
+
+
+
+ + {/* Content Card */} + + +
+
+

+ {t('shortUrl.table.shortUrl')} +

+
+ {displayLink} + +
+
+
+

+ {t('shortUrl.table.destination')} +

+
+ {shortUrl.destinationUrl} + {shortUrl.destinationUrl && } +
+
+
+
+
+ + {/* Analytics */} + + + + + ); +} diff --git a/apps/frontend/src/components/dashboard/shortUrl/ShortUrlFilters.tsx b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlFilters.tsx new file mode 100644 index 00000000..c53b2d36 --- /dev/null +++ b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlFilters.tsx @@ -0,0 +1,59 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Input } from '@/components/ui/input'; +import { MagnifyingGlassIcon, XMarkIcon } from '@heroicons/react/24/outline'; +import { useTranslations } from 'next-intl'; +import { useDebouncedValue } from '@/hooks/use-debounced-value'; +import type { ShortUrlFilters as ShortUrlFiltersType } from '@/lib/api/url-shortener'; + +interface ShortUrlFiltersProps { + filters: ShortUrlFiltersType; + onFiltersChange: (filters: ShortUrlFiltersType) => void; +} + +export function ShortUrlFilters({ filters, onFiltersChange }: ShortUrlFiltersProps) { + const t = useTranslations('collection.filters'); + const [searchValue, setSearchValue] = useState(filters.search ?? ''); + const [debouncedSearch] = useDebouncedValue(searchValue, 400); + + useEffect(() => { + const externalSearch = filters.search ?? ''; + if (externalSearch !== searchValue.trim()) { + setSearchValue(externalSearch); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [filters.search]); + + useEffect(() => { + const trimmed = debouncedSearch.trim(); + if (trimmed !== (filters.search ?? '')) { + onFiltersChange({ ...filters, search: trimmed || undefined }); + } + }, [debouncedSearch, filters, onFiltersChange]); + + return ( +
+
+ + setSearchValue(e.target.value)} + className="pl-9 pr-9 h-9 bg-background" + /> + {searchValue && ( + + )} +
+
+ ); +} diff --git a/apps/frontend/src/components/dashboard/shortUrl/ShortUrlList.tsx b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlList.tsx new file mode 100644 index 00000000..81e08f3b --- /dev/null +++ b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlList.tsx @@ -0,0 +1,221 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { Skeleton } from '@/components/ui/skeleton'; +import { TableCell } from '@/components/ui/table'; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from '@/components/ui/pagination'; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from '@/components/ui/empty'; +import { LinkIcon, PlusIcon } from '@heroicons/react/24/outline'; +import { buttonVariants } from '@/components/ui/button'; +import { cn, getPageNumbers } from '@/lib/utils'; +import { + useListShortUrlsQuery, + type ShortUrlFilters as ShortUrlFiltersType, +} from '@/lib/api/url-shortener'; +import { ShortUrlFilters } from './ShortUrlFilters'; +import { ShortUrlListItem } from './ShortUrlListItem'; +import { CreateShortUrlDialog } from './CreateShortUrlDialog'; + +const ShortUrlTableHeader = () => { + const t = useTranslations('shortUrl.table'); + return ( + + + {t('shortUrl')} + {t('destination')} + {t('status')} + {t('views')} + {t('created')} + + + + ); +}; + +const SkeletonRow = () => ( + + + + + + + + + + + + + + + + + + + + +); + +export function ShortUrlList() { + const t = useTranslations('shortUrl'); + const [currentPage, setCurrentPage] = useState(1); + const [filters, setFilters] = useState({}); + const currentLimit = 10; + + const { + data: shortUrls, + isLoading, + isFetching, + } = useListShortUrlsQuery(currentPage, currentLimit, filters); + + const totalPages = useMemo( + () => (shortUrls ? Math.ceil(shortUrls.total / currentLimit) : 1), + [shortUrls, currentLimit], + ); + + const handleFiltersChange = (newFilters: ShortUrlFiltersType) => { + setFilters(newFilters); + setCurrentPage(1); + }; + + const handlePageChange = (page: number) => { + if (page !== currentPage && page >= 1 && page <= totalPages) { + setCurrentPage(page); + } + }; + + if (isLoading || !shortUrls) { + return ( +
+ + + + {Array.from({ length: 5 }).map((_, idx) => ( + + ))} + +
+
+ ); + } + + const hasActiveFilters = Object.values(filters as Record).some((value) => { + if (value == null) { + return false; + } + + if (typeof value === 'string') { + return value.trim() !== ''; + } + + if (Array.isArray(value)) { + return value.length > 0; + } + + return true; + }); + + if (!isFetching && shortUrls.data.length === 0) { + return ( +
+ {hasActiveFilters && ( + + )} + + + + + + {t('empty.title')} + {t('empty.description')} + + + + + {t('createBtn')} + + } + /> + + +
+ ); + } + + const pageNumbers = getPageNumbers(currentPage, totalPages); + + return ( +
+ +
+ + + + {isFetching + ? (shortUrls.data.length > 0 ? shortUrls.data : Array.from({ length: 5 })).map( + (_, idx) => , + ) + : shortUrls.data.map((su) => )} + +
+
+ {!isFetching && totalPages > 1 && ( + + + + handlePageChange(currentPage - 1)} + aria-disabled={currentPage === 1} + tabIndex={currentPage === 1 ? -1 : 0} + className={currentPage === 1 ? 'pointer-events-none opacity-50' : ''} + /> + + {pageNumbers.map((pageNumber) => ( + + handlePageChange(pageNumber)} + > + {pageNumber} + + + ))} + {totalPages > 5 && currentPage < totalPages - 2 && ( + + + + )} + + handlePageChange(currentPage + 1) + } + aria-disabled={currentPage === totalPages} + tabIndex={currentPage === totalPages ? -1 : 0} + className={currentPage === totalPages ? 'pointer-events-none opacity-50' : ''} + /> + + + + )} +
+ ); +} diff --git a/apps/frontend/src/components/dashboard/shortUrl/ShortUrlListItem.tsx b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlListItem.tsx new file mode 100644 index 00000000..398dc334 --- /dev/null +++ b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlListItem.tsx @@ -0,0 +1,216 @@ +'use client'; + +import { useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useRouter } from 'next/navigation'; +import { TableCell, TableRow } from '@/components/ui/table'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { EllipsisVerticalIcon } from '@heroicons/react/24/outline'; +import { EyeIcon } from 'lucide-react'; +import { Loader2 } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { CopyUrlButton } from '../qrCode/content-renderers/CopyUrlButton'; +import { TruncatedLink } from '../qrCode/content-renderers/TruncatedLink'; +import { + useDeleteShortUrlMutation, + useGetViewsFromShortCodeQuery, + useToggleActiveStateMutation, +} from '@/lib/api/url-shortener'; +import { urlShortenerQueryKeys } from '@/lib/api/url-shortener'; +import { createLinkFromShortUrl, formatDate } from '@/lib/utils'; +import { toast } from '@/components/ui/use-toast'; +import { EditShortUrlDialog } from './EditShortUrlDialog'; +import { DeleteShortUrlDialog } from './DeleteShortUrlDialog'; +import type { TShortUrlWithCustomDomainResponseDto } from '@shared/schemas'; +import { useQueryClient } from '@tanstack/react-query'; +import posthog from 'posthog-js'; +import * as Sentry from '@sentry/nextjs'; +import type { ApiError } from '@/lib/api/ApiError'; + +interface ShortUrlListItemProps { + shortUrl: TShortUrlWithCustomDomainResponseDto; +} + +export function ShortUrlListItem({ shortUrl }: ShortUrlListItemProps) { + const t = useTranslations('shortUrl'); + const tGeneral = useTranslations('general'); + const router = useRouter(); + const queryClient = useQueryClient(); + const [editOpen, setEditOpen] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + + const deleteMutation = useDeleteShortUrlMutation(); + const toggleMutation = useToggleActiveStateMutation(); + const { data: viewsData, isLoading: viewsLoading } = useGetViewsFromShortCodeQuery( + shortUrl.shortCode, + ); + + const link = createLinkFromShortUrl(shortUrl, { short: true }); + const fullLink = createLinkFromShortUrl(shortUrl); + + const handleDelete = () => { + deleteMutation.mutate(shortUrl.shortCode, { + onSuccess: () => { + posthog.capture('short-url-deleted', { + shortCode: shortUrl.shortCode, + }); + toast({ title: t('delete.success') }); + setDeleteOpen(false); + }, + onError: (e) => { + const error = e as ApiError; + if (error.code >= 500) { + Sentry.captureException(error, { + extra: { + shortCode: shortUrl.shortCode, + error: { code: error.code, message: error.message }, + }, + }); + } + posthog.capture('error:short-url-deleted', { + shortCode: shortUrl.shortCode, + error: { code: error.code, message: error.message }, + }); + toast({ + variant: 'destructive', + title: t('error.delete.title'), + description: error.message, + }); + }, + }); + }; + + const handleToggle = () => { + toggleMutation.mutate(shortUrl.shortCode, { + onSuccess: () => { + posthog.capture('short-url-toggled', { + shortCode: shortUrl.shortCode, + isActive: !shortUrl.isActive, + }); + void queryClient.refetchQueries({ + queryKey: urlShortenerQueryKeys.listShortUrls, + }); + }, + onError: (error) => { + Sentry.captureException(error); + toast({ + title: t('error.toggleActiveState.title'), + description: t('error.toggleActiveState.message'), + variant: 'destructive', + }); + }, + }); + }; + + return ( + <> + router.push(`/dashboard/short-urls/${shortUrl.shortCode}`)} + > + {/* Short URL */} + +
+ {link} + +
+
+ + {/* Destination */} + + {shortUrl.destinationUrl && ( +
+ + +
+ )} +
+ + {/* Status */} + + + {shortUrl.isActive ? t('status.active') : t('status.inactive')} + + + + {/* Views */} + + {viewsLoading ? ( +
+ + +
+ ) : ( + viewsData?.views !== undefined && ( + + +
+ + {viewsData.views} +
+
+ + {viewsData.views} {t('table.views')} + +
+ ) + )} +
+ + {/* Created */} + + {formatDate(shortUrl.createdAt)} + + + {/* Actions */} + e.stopPropagation()}> + + + + + + router.push(`/dashboard/short-urls/${shortUrl.shortCode}`)} + className="cursor-pointer" + > + {t('viewDetails')} + + setEditOpen(true)} className="cursor-pointer"> + {tGeneral('edit')} + + + {shortUrl.isActive ? t('status.disable') : t('status.enable')} + + setDeleteOpen(true)} + className="cursor-pointer text-destructive" + > + {tGeneral('delete')} + + + + +
+ + + + + ); +} diff --git a/apps/frontend/src/components/dashboard/tags/TagList.tsx b/apps/frontend/src/components/dashboard/tags/TagList.tsx index b7062137..0914e632 100644 --- a/apps/frontend/src/components/dashboard/tags/TagList.tsx +++ b/apps/frontend/src/components/dashboard/tags/TagList.tsx @@ -203,7 +203,6 @@ export const TagList = () => { )} handlePageChange(currentPage + 1) } diff --git a/apps/frontend/src/components/dashboard/templates/TemplateList.tsx b/apps/frontend/src/components/dashboard/templates/TemplateList.tsx index e810fc98..071dbdb7 100644 --- a/apps/frontend/src/components/dashboard/templates/TemplateList.tsx +++ b/apps/frontend/src/components/dashboard/templates/TemplateList.tsx @@ -199,7 +199,6 @@ export const TemplateList = ({ onCreateTemplate }: TemplateListProps) => { )} handlePageChange(currentPage + 1) } diff --git a/apps/frontend/src/components/features/FeatureMockups.tsx b/apps/frontend/src/components/features/FeatureMockups.tsx index a733c3de..d5c45c80 100644 --- a/apps/frontend/src/components/features/FeatureMockups.tsx +++ b/apps/frontend/src/components/features/FeatureMockups.tsx @@ -486,6 +486,65 @@ export function ContentTypesMockup() { ); } +export function ShortUrlMockup() { + const links = [ + { short: 'qrcodly.de/launch', clicks: 1284, trend: '+18%' }, + { short: 'qrcodly.de/promo', clicks: 847, trend: '+9%' }, + { short: 'qrcodly.de/docs', clicks: 531, trend: '+24%' }, + ]; + return ( +
+
+
+
+ + Short URLs +
+ + 3 Active + +
+ +
+ {links.map((link, i) => ( + +
+ + {link.short} + + + {link.trend} + +
+
+
+ +
+ + {link.clicks.toLocaleString()} + +
+
+ ))} +
+
+
+ ); +} + export function TeamsMockup() { const members = [ { diff --git a/apps/frontend/src/components/qr-generator/content/DomainSelector.tsx b/apps/frontend/src/components/qr-generator/content/DomainSelector.tsx index 2eb7b886..86456395 100644 --- a/apps/frontend/src/components/qr-generator/content/DomainSelector.tsx +++ b/apps/frontend/src/components/qr-generator/content/DomainSelector.tsx @@ -13,6 +13,7 @@ import { FormControl, FormDescription, FormItem, FormLabel } from '@/components/ import { GlobeAltIcon } from '@heroicons/react/24/outline'; import { Skeleton } from '@/components/ui/skeleton'; import Link from 'next/link'; +import { getSystemDomain } from '@/lib/utils'; interface DomainSelectorProps { value: string | null | undefined; @@ -68,7 +69,7 @@ export function DomainSelector({ value, onChange, disabled }: DomainSelectorProp
- {t('defaultDomain')} + {getSystemDomain()}
{verifiedDomains.map((domain) => ( diff --git a/apps/frontend/src/dictionaries/de.json b/apps/frontend/src/dictionaries/de.json index 235e5a03..9e65a554 100644 --- a/apps/frontend/src/dictionaries/de.json +++ b/apps/frontend/src/dictionaries/de.json @@ -3,10 +3,75 @@ "nav": { "collection": "Sammlungen", "qrCodes": "QR Codes", + "shortUrls": "Kurz-URLs", "templates": "Vorlagen", "tags": "Tags" } }, + "shortUrl": { + "title": "Kurz-URLs", + "description": "Kürze, verwalte und verfolge deine Links mit detaillierten Analysen", + "createBtn": "Kurz-URL erstellen", + "table": { + "shortUrl": "Kurz-URL", + "destination": "Ziel", + "status": "Status", + "views": "Aufrufe", + "created": "Erstellt" + }, + "create": { + "title": "Kurz-URL erstellen", + "destinationLabel": "Ziel-URL", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Eigene Domain", + "submitBtn": "Erstellen", + "success": "Kurz-URL erfolgreich erstellt" + }, + "edit": { + "title": "Kurz-URL bearbeiten", + "submitBtn": "Aktualisieren", + "success": "Kurz-URL erfolgreich aktualisiert" + }, + "viewDetails": "Details anzeigen", + "delete": { + "title": "Kurz-URL löschen", + "confirm": "Bist du sicher, dass du diese Kurz-URL löschen möchtest? Diese Aktion kann nicht rückgängig gemacht werden.", + "success": "Kurz-URL erfolgreich gelöscht" + }, + "empty": { + "title": "Noch keine Kurz-URLs", + "description": "Erstelle deine erste Kurz-URL, um loszulegen" + }, + "status": { + "active": "Aktiv", + "inactive": "Deaktiviert", + "enable": "Aktivieren", + "disable": "Deaktivieren" + }, + "error": { + "toggleActiveState": { + "title": "Fehler beim Ändern des Status", + "message": "Beim Ändern des Status ist ein Fehler aufgetreten. Wir wurden benachrichtigt und werden das Problem so schnell wie möglich beheben." + }, + "create": { + "title": "Fehler beim Erstellen der Kurz-URL", + "message": "Beim Erstellen der Kurz-URL ist ein Fehler aufgetreten. Bitte versuche es erneut." + }, + "update": { + "title": "Fehler beim Aktualisieren der Kurz-URL", + "message": "Beim Aktualisieren der Kurz-URL ist ein Fehler aufgetreten. Bitte versuche es erneut." + }, + "delete": { + "title": "Fehler beim Löschen der Kurz-URL", + "message": "Beim Löschen der Kurz-URL ist ein Fehler aufgetreten. Bitte versuche es erneut." + } + }, + "featureBanner": { + "title": "Neu: Kurz-URLs", + "description": "Ab sofort kannst du Kurz-URLs direkt erstellen und verwalten — Links kürzen, Klicks tracken und detaillierte Analysen einsehen. Kein QR Code nötig.", + "cta": "Jetzt ausprobieren" + } + }, "general": { "signOut": "Abmelden", "proRequired": "Pro-Abo erforderlich", @@ -153,6 +218,11 @@ "subHeadline1": "Du hast eine Frage zu QRcodly oder möchtest Feedback teilen? Wir helfen gerne und arbeiten ständig daran, uns zu verbessern.", "emailBtn": "Kontaktiere uns" }, + "browserExtensionTeaser": { + "badge": "Demnächst verfügbar", + "headline": "Browser-Erweiterung", + "description": "Erstelle QR-Codes im Handumdrehen – direkt aus deinem Browser. Unsere kommende Erweiterung lässt dich QR-Codes für jede Seite mit nur einem Klick generieren und verwalten." + }, "featuresCta": { "headline": "Übersicht der wichtigsten Funktionen", "subHeadline": "Mit QRcodly kannst du QR Codes kostenlos erstellen, bearbeiten, verwalten und analysieren – alles an einem Ort.", @@ -190,6 +260,11 @@ "headline": "Im Team zusammenarbeiten", "subHeadline": "Lade Teammitglieder ein und arbeite gemeinsam an QR Codes. Ideal für Unternehmen, Agenturen und Teams." }, + "shortUrlFeature": { + "headline": "Eigenständige Kurzlinks", + "subHeadline": "Kürze, verwalte und verfolge deine Links mit detaillierten Analysen – ganz ohne QR-Code.", + "actionLabel": "Jetzt ausprobieren" + }, "secureFeature": { "headline": "Deine Daten bleiben bei uns", "subHeadline": "QRcodly hostet alle Daten lokal in Deutschland – keine Weitergabe an externe Dienste. Einzig für die Authentifizierung nutzen wir Clerk." @@ -198,14 +273,6 @@ "scrollRight": "Nach rechts scrollen" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Fehler beim Ändern des Status", - "message": "Beim Ändern des Status ist ein Fehler aufgetreten. Wir wurden benachrichtigt und werden das Problem so schnell wie möglich beheben." - } - } - }, "qrCodeDisabled": { "title": "QR Code deaktiviert", "description": "Dieser QR Code ist leider nicht mehr aktiv. Das Ziel ist nicht mehr verfügbar.", @@ -675,7 +742,6 @@ "domainSelector": { "label": "Kurz-URL Domain", "placeholder": "Domain auswählen", - "defaultDomain": "Standard (qrcodly.de)", "description": "Wähle die Domain für deine Kurz-URL", "noDomains": "Keine eigenen Domains verfügbar.", "addDomain": "Domain hinzufügen" @@ -1281,6 +1347,14 @@ "bullet2": "Jeden Scan mit Echtzeit-Analysen verfolgen", "bullet3": "QR Codes sofort aktivieren oder deaktivieren" }, + "shortUrl": { + "tag": "Neue Funktion", + "title": "Eigenständige Kurzlinks", + "description": "Erstelle und verwalte Kurzlinks unabhängig – ganz ohne QR-Code. Kürze beliebige Links, verfolge Klicks in Echtzeit und gewinne Einblicke mit detaillierten Analysen.", + "bullet1": "Jede URL mit einem Klick kürzen", + "bullet2": "Klicks mit Echtzeit-Analysen verfolgen", + "bullet3": "Funktioniert mit eigenen Domains für gebrandete Links" + }, "analytics": { "tag": "Einblicke", "title": "Detaillierte Echtzeit-Analysen", diff --git a/apps/frontend/src/dictionaries/en.json b/apps/frontend/src/dictionaries/en.json index fdfb2fae..9f3bdd17 100644 --- a/apps/frontend/src/dictionaries/en.json +++ b/apps/frontend/src/dictionaries/en.json @@ -3,10 +3,75 @@ "nav": { "collection": "Collections", "qrCodes": "QR Codes", + "shortUrls": "Short URLs", "templates": "Templates", "tags": "Tags" } }, + "shortUrl": { + "title": "Short URLs", + "description": "Shorten, manage, and track your links with detailed analytics", + "createBtn": "Create Short URL", + "table": { + "shortUrl": "Short URL", + "destination": "Destination", + "status": "Status", + "views": "Views", + "created": "Created" + }, + "create": { + "title": "Create Short URL", + "destinationLabel": "Destination URL", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Custom Domain", + "submitBtn": "Create", + "success": "Short URL created successfully" + }, + "edit": { + "title": "Edit Short URL", + "submitBtn": "Update", + "success": "Short URL updated successfully" + }, + "viewDetails": "View Details", + "delete": { + "title": "Delete Short URL", + "confirm": "Are you sure you want to delete this short URL? This action cannot be undone.", + "success": "Short URL deleted successfully" + }, + "empty": { + "title": "No short URLs yet", + "description": "Create your first short URL to get started" + }, + "status": { + "active": "Active", + "inactive": "Disabled", + "enable": "Enable", + "disable": "Disable" + }, + "error": { + "toggleActiveState": { + "title": "Error Changing State", + "message": "There was an error changing the state. We've been notified and will fix it as soon as possible." + }, + "create": { + "title": "Error Creating Short URL", + "message": "There was an error creating the short URL. Please try again." + }, + "update": { + "title": "Error Updating Short URL", + "message": "There was an error updating the short URL. Please try again." + }, + "delete": { + "title": "Error Deleting Short URL", + "message": "There was an error deleting the short URL. Please try again." + } + }, + "featureBanner": { + "title": "Introducing Short URLs", + "description": "You can now create and manage short URLs directly — shorten any link, track clicks, and view detailed analytics. No QR code needed.", + "cta": "Try it now" + } + }, "general": { "signOut": "Sign Out", "proRequired": "Pro Plan Required", @@ -153,6 +218,11 @@ "subHeadline1": "Have a question about QRcodly or want to share feedback? We're happy to help and always looking to improve.", "emailBtn": "Contact Us" }, + "browserExtensionTeaser": { + "badge": "Coming Soon", + "headline": "Browser Extension", + "description": "Create QR codes on the fly — right from your browser. Our upcoming extension lets you generate and manage QR codes for any page with just one click." + }, "featuresCta": { "headline": "Overview of Key Features", "subHeadline": "With QRcodly you can create, edit, manage, and analyze QR codes – all in one place.", @@ -190,6 +260,11 @@ "headline": "Collaborate in Teams", "subHeadline": "Invite team members and work together on QR codes. Perfect for companies, agencies, and teams." }, + "shortUrlFeature": { + "headline": "Standalone Short URLs", + "subHeadline": "Shorten, manage, and track your links with detailed analytics – no QR code required.", + "actionLabel": "Try it now" + }, "secureFeature": { "headline": "Your data stays with us", "subHeadline": "QRcodly hosts all data locally in Germany – no sharing with external services. Only for authentication, we use Clerk." @@ -198,14 +273,6 @@ "scrollRight": "Scroll right" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Error Changing State", - "message": "There was an error changing the state. We've been notified and will fix it as soon as possible." - } - } - }, "qrCodeDisabled": { "title": "QR code Disabled", "description": "Sorry, this QR code is no longer active. The destination it pointed to is unavailable.", @@ -675,7 +742,6 @@ "domainSelector": { "label": "Short URL Domain", "placeholder": "Select a domain", - "defaultDomain": "Default (qrcodly.de)", "description": "Choose which domain to use for your short URL", "noDomains": "No custom domains available.", "addDomain": "Add a domain" @@ -1281,6 +1347,14 @@ "bullet2": "Track every scan with real-time analytics", "bullet3": "Toggle QR codes on or off instantly" }, + "shortUrl": { + "tag": "New Feature", + "title": "Standalone Short URLs", + "description": "Create and manage short URLs independently — no QR code needed. Shorten any link, track clicks in real time, and gain insights with detailed analytics.", + "bullet1": "Shorten any URL with a single click", + "bullet2": "Track clicks with real-time analytics", + "bullet3": "Works with custom domains for branded links" + }, "analytics": { "tag": "Insights", "title": "Detailed Real-Time Analytics", diff --git a/apps/frontend/src/dictionaries/es.json b/apps/frontend/src/dictionaries/es.json index 9a4dff53..96cdeb47 100644 --- a/apps/frontend/src/dictionaries/es.json +++ b/apps/frontend/src/dictionaries/es.json @@ -3,10 +3,75 @@ "nav": { "collection": "Colecciones", "qrCodes": "Códigos QR", + "shortUrls": "URLs cortas", "templates": "Plantillas", "tags": "Etiquetas" } }, + "shortUrl": { + "title": "URLs cortas", + "description": "Acorta, gestiona y rastrea tus enlaces con analíticas detalladas", + "createBtn": "Crear URL corta", + "table": { + "shortUrl": "URL corta", + "destination": "Destino", + "status": "Estado", + "views": "Visitas", + "created": "Creado" + }, + "create": { + "title": "Crear URL corta", + "destinationLabel": "URL de destino", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Dominio personalizado", + "submitBtn": "Crear", + "success": "URL corta creada correctamente" + }, + "edit": { + "title": "Editar URL corta", + "submitBtn": "Actualizar", + "success": "URL corta actualizada correctamente" + }, + "viewDetails": "Ver detalles", + "delete": { + "title": "Eliminar URL corta", + "confirm": "¿Estás seguro de que quieres eliminar esta URL corta? Esta acción no se puede deshacer.", + "success": "URL corta eliminada correctamente" + }, + "empty": { + "title": "Aún no hay URLs cortas", + "description": "Crea tu primera URL corta para empezar" + }, + "status": { + "active": "Activa", + "inactive": "Desactivado", + "enable": "Activar", + "disable": "Desactivar" + }, + "error": { + "toggleActiveState": { + "title": "Error al cambiar el estado", + "message": "Se ha producido un error al cambiar el estado. Hemos sido notificados y solucionaremos el problema lo antes posible." + }, + "create": { + "title": "Error al crear la URL corta", + "message": "Hubo un error al crear la URL corta. Por favor, inténtelo de nuevo." + }, + "update": { + "title": "Error al actualizar la URL corta", + "message": "Hubo un error al actualizar la URL corta. Por favor, inténtelo de nuevo." + }, + "delete": { + "title": "Error al eliminar la URL corta", + "message": "Hubo un error al eliminar la URL corta. Por favor, inténtelo de nuevo." + } + }, + "featureBanner": { + "title": "Presentamos URLs cortas", + "description": "Ahora puedes crear y gestionar URLs cortas directamente — acorta enlaces, rastrea clics y consulta analíticas detalladas. Sin necesidad de código QR.", + "cta": "Pruébalo ahora" + } + }, "general": { "signOut": "Cerrar sesión", "proRequired": "Se requiere Plan Pro", @@ -153,6 +218,11 @@ "subHeadline1": "¿Tienes alguna pregunta sobre QRcodly o quieres compartir tu opinión? Estamos encantados de ayudarte y siempre buscamos mejorar.", "emailBtn": "Contáctanos" }, + "browserExtensionTeaser": { + "badge": "Próximamente", + "headline": "Extensión del Navegador", + "description": "Crea códigos QR sobre la marcha — directamente desde tu navegador. Nuestra próxima extensión te permite generar y gestionar códigos QR para cualquier página con un solo clic." + }, "featuresCta": { "headline": "Visión general de las características clave", "subHeadline": "Con QRcodly puedes crear, editar, gestionar y analizar códigos QR de forma gratuita, todo en un solo lugar.", @@ -190,6 +260,11 @@ "headline": "Colaborar en equipo", "subHeadline": "Invita a miembros del equipo y trabajen juntos en códigos QR. Ideal para empresas, agencias y equipos." }, + "shortUrlFeature": { + "headline": "URLs cortas independientes", + "subHeadline": "Acorta, gestiona y rastrea tus enlaces con análisis detallados – sin necesidad de código QR.", + "actionLabel": "Pruébalo ahora" + }, "secureFeature": { "headline": "Tus datos permanecen con nosotros", "subHeadline": "QRcodly aloja todos los datos localmente en Alemania – sin compartirlos con servicios externos. Solo usamos Clerk para la autenticación." @@ -198,14 +273,6 @@ "scrollRight": "Desplazar a la derecha" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Error al cambiar el estado", - "message": "Se ha producido un error al cambiar el estado. Hemos sido notificados y solucionaremos el problema lo antes posible." - } - } - }, "qrCodeDisabled": { "title": "Código QR deshabilitado", "description": "Lamentablemente, este código QR ya no está activo. El destino ya no está disponible.", @@ -395,7 +462,6 @@ "domainSelector": { "label": "Dominio de URL corta", "placeholder": "Seleccionar un dominio", - "defaultDomain": "Predeterminado (qrcodly.de)", "description": "Elige qué dominio usar para tu URL corta", "noDomains": "No hay dominios personalizados disponibles.", "addDomain": "Agregar un dominio" @@ -1285,6 +1351,14 @@ "bullet2": "Rastrea cada escaneo con analíticas en tiempo real", "bullet3": "Activa o desactiva códigos QR al instante" }, + "shortUrl": { + "tag": "Nueva Función", + "title": "URLs Cortas Independientes", + "description": "Crea y gestiona URLs cortas de forma independiente — sin necesidad de código QR. Acorta cualquier enlace, rastrea clics en tiempo real y obtén información con análisis detallados.", + "bullet1": "Acorta cualquier URL con un solo clic", + "bullet2": "Rastrea clics con análisis en tiempo real", + "bullet3": "Compatible con dominios personalizados para enlaces de marca" + }, "analytics": { "tag": "Información", "title": "Analíticas Detalladas en Tiempo Real", diff --git a/apps/frontend/src/dictionaries/fr.json b/apps/frontend/src/dictionaries/fr.json index 4c677d79..adaa7e13 100644 --- a/apps/frontend/src/dictionaries/fr.json +++ b/apps/frontend/src/dictionaries/fr.json @@ -3,10 +3,75 @@ "nav": { "collection": "Collections", "qrCodes": "Codes QR", + "shortUrls": "URLs courtes", "templates": "Modèles", "tags": "Étiquettes" } }, + "shortUrl": { + "title": "URLs courtes", + "description": "Raccourcissez, gérez et suivez vos liens avec des analyses détaillées", + "createBtn": "Créer une URL courte", + "table": { + "shortUrl": "URL courte", + "destination": "Destination", + "status": "Statut", + "views": "Vues", + "created": "Créé" + }, + "create": { + "title": "Créer une URL courte", + "destinationLabel": "URL de destination", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Domaine personnalisé", + "submitBtn": "Créer", + "success": "URL courte créée avec succès" + }, + "edit": { + "title": "Modifier l'URL courte", + "submitBtn": "Mettre à jour", + "success": "URL courte mise à jour avec succès" + }, + "viewDetails": "Voir les détails", + "delete": { + "title": "Supprimer l'URL courte", + "confirm": "Êtes-vous sûr de vouloir supprimer cette URL courte ? Cette action est irréversible.", + "success": "URL courte supprimée avec succès" + }, + "empty": { + "title": "Pas encore d'URLs courtes", + "description": "Créez votre première URL courte pour commencer" + }, + "status": { + "active": "Actif", + "inactive": "Désactivé", + "enable": "Activer", + "disable": "Désactiver" + }, + "error": { + "toggleActiveState": { + "title": "Erreur lors du changement d'état", + "message": "Une erreur s'est produite lors du changement d'état. Nous avons été informés et résoudrons le problème dès que possible." + }, + "create": { + "title": "Erreur lors de la création de l'URL courte", + "message": "Une erreur est survenue lors de la création de l'URL courte. Veuillez réessayer." + }, + "update": { + "title": "Erreur lors de la mise à jour de l'URL courte", + "message": "Une erreur est survenue lors de la mise à jour de l'URL courte. Veuillez réessayer." + }, + "delete": { + "title": "Erreur lors de la suppression de l'URL courte", + "message": "Une erreur est survenue lors de la suppression de l'URL courte. Veuillez réessayer." + } + }, + "featureBanner": { + "title": "Découvrez les URLs courtes", + "description": "Vous pouvez désormais créer et gérer des URLs courtes directement — raccourcissez vos liens, suivez les clics et consultez des analyses détaillées. Aucun code QR nécessaire.", + "cta": "Essayer maintenant" + } + }, "general": { "signOut": "Se déconnecter", "proRequired": "Abonnement Pro requis", @@ -153,6 +218,11 @@ "subHeadline1": "Vous avez une question sur QRcodly ou souhaitez partager vos retours ? Nous sommes là pour vous aider et cherchons toujours à nous améliorer.", "emailBtn": "Contactez-nous" }, + "browserExtensionTeaser": { + "badge": "Bientôt disponible", + "headline": "Extension Navigateur", + "description": "Créez des codes QR à la volée — directement depuis votre navigateur. Notre extension à venir vous permet de générer et gérer des codes QR pour n'importe quelle page en un seul clic." + }, "featuresCta": { "headline": "Aperçu des principales fonctionnalités", "subHeadline": "Avec QRcodly, vous pouvez créer, modifier, gérer et analyser des codes QR gratuitement, tout en un seul endroit.", @@ -190,6 +260,11 @@ "headline": "Collaborer en équipe", "subHeadline": "Invitez des membres de l'équipe et travaillez ensemble sur les codes QR. Idéal pour les entreprises, les agences et les équipes." }, + "shortUrlFeature": { + "headline": "URLs courtes autonomes", + "subHeadline": "Raccourcissez, gérez et suivez vos liens avec des analyses détaillées – sans code QR nécessaire.", + "actionLabel": "Essayer maintenant" + }, "secureFeature": { "headline": "Vos données restent chez nous", "subHeadline": "QRcodly héberge toutes les données localement en Allemagne – sans les partager avec des services externes. Nous utilisons uniquement Clerk pour l’authentification." @@ -198,14 +273,6 @@ "scrollRight": "Défiler vers la droite" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Erreur lors du changement d'état", - "message": "Une erreur s'est produite lors du changement d'état. Nous avons été informés et résoudrons le problème dès que possible." - } - } - }, "qrCodeDisabled": { "title": "Code QR désactivé", "description": "Désolé, ce code QR n'est plus actif. La destination n'est plus disponible.", @@ -395,7 +462,6 @@ "domainSelector": { "label": "Domaine de l'URL courte", "placeholder": "Sélectionner un domaine", - "defaultDomain": "Par défaut (qrcodly.de)", "description": "Choisissez le domaine à utiliser pour votre URL courte", "noDomains": "Aucun domaine personnalisé disponible.", "addDomain": "Ajouter un domaine" @@ -1285,6 +1351,14 @@ "bullet2": "Suivez chaque scan avec des analyses en temps réel", "bullet3": "Activez ou désactivez les QR codes instantanément" }, + "shortUrl": { + "tag": "Nouvelle Fonctionnalité", + "title": "URLs Courtes Autonomes", + "description": "Créez et gérez des URLs courtes de manière indépendante — sans code QR nécessaire. Raccourcissez n'importe quel lien, suivez les clics en temps réel et obtenez des informations détaillées.", + "bullet1": "Raccourcissez n'importe quelle URL en un clic", + "bullet2": "Suivez les clics avec des analyses en temps réel", + "bullet3": "Compatible avec les domaines personnalisés pour des liens de marque" + }, "analytics": { "tag": "Analyses", "title": "Analyses détaillées en temps réel", diff --git a/apps/frontend/src/dictionaries/it.json b/apps/frontend/src/dictionaries/it.json index e3117fdb..fd631f01 100644 --- a/apps/frontend/src/dictionaries/it.json +++ b/apps/frontend/src/dictionaries/it.json @@ -3,10 +3,75 @@ "nav": { "collection": "Collezioni", "qrCodes": "Codici QR", + "shortUrls": "URL brevi", "templates": "Modelli", "tags": "Etichette" } }, + "shortUrl": { + "title": "URL brevi", + "description": "Accorcia, gestisci e monitora i tuoi link con analisi dettagliate", + "createBtn": "Crea URL breve", + "table": { + "shortUrl": "URL breve", + "destination": "Destinazione", + "status": "Stato", + "views": "Visualizzazioni", + "created": "Creato" + }, + "create": { + "title": "Crea URL breve", + "destinationLabel": "URL di destinazione", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Dominio personalizzato", + "submitBtn": "Crea", + "success": "URL breve creato con successo" + }, + "edit": { + "title": "Modifica URL breve", + "submitBtn": "Aggiorna", + "success": "URL breve aggiornato con successo" + }, + "viewDetails": "Visualizza dettagli", + "delete": { + "title": "Elimina URL breve", + "confirm": "Sei sicuro di voler eliminare questo URL breve? Questa azione non può essere annullata.", + "success": "URL breve eliminato con successo" + }, + "empty": { + "title": "Nessun URL breve ancora", + "description": "Crea il tuo primo URL breve per iniziare" + }, + "status": { + "active": "Attivo", + "inactive": "Disattivato", + "enable": "Attiva", + "disable": "Disattiva" + }, + "error": { + "toggleActiveState": { + "title": "Errore durante la modifica dello stato", + "message": "Si è verificato un errore durante la modifica dello stato. Siamo stati avvisati e risolveremo il problema il prima possibile." + }, + "create": { + "title": "Errore nella creazione dell'URL breve", + "message": "Si è verificato un errore nella creazione dell'URL breve. Riprova." + }, + "update": { + "title": "Errore nell'aggiornamento dell'URL breve", + "message": "Si è verificato un errore nell'aggiornamento dell'URL breve. Riprova." + }, + "delete": { + "title": "Errore nell'eliminazione dell'URL breve", + "message": "Si è verificato un errore nell'eliminazione dell'URL breve. Riprova." + } + }, + "featureBanner": { + "title": "Scopri gli URL brevi", + "description": "Ora puoi creare e gestire URL brevi direttamente — accorcia i link, monitora i clic e consulta analisi dettagliate. Nessun codice QR necessario.", + "cta": "Provalo ora" + } + }, "general": { "signOut": "Disconnetti", "proRequired": "Richiesto Piano Pro", @@ -153,6 +218,11 @@ "subHeadline1": "Hai una domanda su QRcodly o vuoi condividere un feedback? Siamo felici di aiutarti e lavoriamo sempre per migliorare.", "emailBtn": "Contattaci" }, + "browserExtensionTeaser": { + "badge": "In arrivo", + "headline": "Estensione Browser", + "description": "Crea codici QR al volo — direttamente dal tuo browser. La nostra prossima estensione ti permette di generare e gestire codici QR per qualsiasi pagina con un solo clic." + }, "featuresCta": { "headline": "Panoramica delle funzionalità chiave", "subHeadline": "Con QRcodly puoi creare, modificare, gestire e analizzare codici QR gratuitamente – tutto in un unico posto.", @@ -190,6 +260,11 @@ "headline": "Collabora in team", "subHeadline": "Invita i membri del team e lavora insieme sui codici QR. Ideale per aziende, agenzie e team." }, + "shortUrlFeature": { + "headline": "URL brevi indipendenti", + "subHeadline": "Accorcia, gestisci e monitora i tuoi link con analisi dettagliate – senza bisogno di un codice QR.", + "actionLabel": "Provalo ora" + }, "secureFeature": { "headline": "I tuoi dati rimangono con noi", "subHeadline": "QRcodly ospita tutti i dati localmente in Germania – nessuna condivisione con servizi esterni. Usiamo Clerk solo per l'autenticazione." @@ -198,14 +273,6 @@ "scrollRight": "Scorri a destra" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Errore durante la modifica dello stato", - "message": "Si è verificato un errore durante la modifica dello stato. Siamo stati avvisati e risolveremo il problema il prima possibile." - } - } - }, "qrCodeDisabled": { "title": "Codice QR disabilitato", "description": "Spiacenti, questo codice QR non è più attivo. La destinazione non è più disponibile.", @@ -395,7 +462,6 @@ "domainSelector": { "label": "Dominio URL breve", "placeholder": "Seleziona un dominio", - "defaultDomain": "Predefinito (qrcodly.de)", "description": "Scegli quale dominio usare per il tuo URL breve", "noDomains": "Nessun dominio personalizzato disponibile.", "addDomain": "Aggiungi un dominio" @@ -1281,6 +1347,14 @@ "bullet2": "Monitora ogni scansione con analisi in tempo reale", "bullet3": "Attiva o disattiva i QR code istantaneamente" }, + "shortUrl": { + "tag": "Nuova Funzione", + "title": "URL Brevi Indipendenti", + "description": "Crea e gestisci URL brevi in modo indipendente — senza bisogno di un codice QR. Accorcia qualsiasi link, monitora i clic in tempo reale e ottieni informazioni con analisi dettagliate.", + "bullet1": "Accorcia qualsiasi URL con un solo clic", + "bullet2": "Monitora i clic con analisi in tempo reale", + "bullet3": "Compatibile con domini personalizzati per link brandizzati" + }, "analytics": { "tag": "Approfondimenti", "title": "Analisi Dettagliate in Tempo Reale", diff --git a/apps/frontend/src/dictionaries/nl.json b/apps/frontend/src/dictionaries/nl.json index 1abd5e8e..365ee2d4 100644 --- a/apps/frontend/src/dictionaries/nl.json +++ b/apps/frontend/src/dictionaries/nl.json @@ -3,10 +3,75 @@ "nav": { "collection": "Collecties", "qrCodes": "QR Codes", + "shortUrls": "Korte URLs", "templates": "Sjablonen", "tags": "Tags" } }, + "shortUrl": { + "title": "Korte URLs", + "description": "Verkort, beheer en volg je links met gedetailleerde analyses", + "createBtn": "Korte URL aanmaken", + "table": { + "shortUrl": "Korte URL", + "destination": "Bestemming", + "status": "Status", + "views": "Weergaven", + "created": "Aangemaakt" + }, + "create": { + "title": "Korte URL aanmaken", + "destinationLabel": "Bestemmings-URL", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Eigen domein", + "submitBtn": "Aanmaken", + "success": "Korte URL succesvol aangemaakt" + }, + "edit": { + "title": "Korte URL bewerken", + "submitBtn": "Bijwerken", + "success": "Korte URL succesvol bijgewerkt" + }, + "viewDetails": "Details bekijken", + "delete": { + "title": "Korte URL verwijderen", + "confirm": "Weet je zeker dat je deze korte URL wilt verwijderen? Deze actie kan niet ongedaan worden gemaakt.", + "success": "Korte URL succesvol verwijderd" + }, + "empty": { + "title": "Nog geen korte URLs", + "description": "Maak je eerste korte URL aan om te beginnen" + }, + "status": { + "active": "Actief", + "inactive": "Uitgeschakeld", + "enable": "Activeren", + "disable": "Deactiveren" + }, + "error": { + "toggleActiveState": { + "title": "Fout bij het wijzigen van de status", + "message": "Er is een fout opgetreden bij het wijzigen van de status. We zijn hiervan op de hoogte gesteld en zullen het probleem zo snel mogelijk oplossen." + }, + "create": { + "title": "Fout bij het aanmaken van de korte URL", + "message": "Er is een fout opgetreden bij het aanmaken van de korte URL. Probeer het opnieuw." + }, + "update": { + "title": "Fout bij het bijwerken van de korte URL", + "message": "Er is een fout opgetreden bij het bijwerken van de korte URL. Probeer het opnieuw." + }, + "delete": { + "title": "Fout bij het verwijderen van de korte URL", + "message": "Er is een fout opgetreden bij het verwijderen van de korte URL. Probeer het opnieuw." + } + }, + "featureBanner": { + "title": "Maak kennis met korte URLs", + "description": "Je kunt nu direct korte URLs aanmaken en beheren — verkort links, volg klikken en bekijk gedetailleerde analyses. Geen QR-code nodig.", + "cta": "Probeer het nu" + } + }, "general": { "signOut": "Uitloggen", "proRequired": "Pro-abonnement vereist", @@ -153,6 +218,11 @@ "subHeadline1": "Heb je een vraag over QRcodly of wil je feedback delen? We helpen je graag en werken continu aan verbeteringen.", "emailBtn": "Neem contact op" }, + "browserExtensionTeaser": { + "badge": "Binnenkort beschikbaar", + "headline": "Browser-extensie", + "description": "Maak QR-codes onderweg — rechtstreeks vanuit je browser. Onze aankomende extensie laat je QR-codes genereren en beheren voor elke pagina met slechts één klik." + }, "featuresCta": { "headline": "Overzicht van de belangrijkste functies", "subHeadline": "Met QRcodly kunt u gratis QR-codes maken, bewerken, beheren en analyseren - alles op één plek.", @@ -190,6 +260,11 @@ "headline": "Samenwerken in een team", "subHeadline": "Nodig teamleden uit en werk samen aan QR-codes. Ideaal voor bedrijven, bureaus en teams." }, + "shortUrlFeature": { + "headline": "Zelfstandige korte URLs", + "subHeadline": "Verkort, beheer en volg je links met gedetailleerde analyses – geen QR-code nodig.", + "actionLabel": "Nu uitproberen" + }, "secureFeature": { "headline": "Je gegevens blijven bij ons", "subHeadline": "QRcodly host alle gegevens lokaal in Duitsland – geen doorsturing naar externe diensten. Alleen voor authenticatie gebruiken we Clerk." @@ -198,14 +273,6 @@ "scrollRight": "Naar rechts scrollen" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Fout bij het wijzigen van de status", - "message": "Er is een fout opgetreden bij het wijzigen van de status. We zijn hiervan op de hoogte gesteld en zullen het probleem zo snel mogelijk oplossen." - } - } - }, "qrCodeDisabled": { "title": "QR-code uitgeschakeld", "description": "Deze QR-code is helaas niet meer actief. De bestemming is niet meer beschikbaar.", @@ -395,7 +462,6 @@ "domainSelector": { "label": "Korte URL-domein", "placeholder": "Selecteer een domein", - "defaultDomain": "Standaard (qrcodly.de)", "description": "Kies welk domein je wilt gebruiken voor je korte URL", "noDomains": "Geen aangepaste domeinen beschikbaar.", "addDomain": "Domein toevoegen" @@ -1281,6 +1347,14 @@ "bullet2": "Volg elke scan met realtime analyse", "bullet3": "Schakel QR-codes direct aan of uit" }, + "shortUrl": { + "tag": "Nieuwe Functie", + "title": "Zelfstandige Korte URLs", + "description": "Maak en beheer korte URLs onafhankelijk — geen QR-code nodig. Verkort elke link, volg klikken in realtime en krijg inzichten met gedetailleerde analyses.", + "bullet1": "Verkort elke URL met één klik", + "bullet2": "Volg klikken met realtime analyse", + "bullet3": "Werkt met eigen domeinen voor merkgebonden links" + }, "analytics": { "tag": "Inzichten", "title": "Gedetailleerde Realtime Analyse", diff --git a/apps/frontend/src/dictionaries/pl.json b/apps/frontend/src/dictionaries/pl.json index 036e2714..dc9b08cf 100644 --- a/apps/frontend/src/dictionaries/pl.json +++ b/apps/frontend/src/dictionaries/pl.json @@ -3,10 +3,75 @@ "nav": { "collection": "Kolekcje", "qrCodes": "Kody QR", + "shortUrls": "Krótkie URLe", "templates": "Szablony", "tags": "Tagi" } }, + "shortUrl": { + "title": "Krótkie URLe", + "description": "Skracaj, zarządzaj i śledź swoje linki ze szczegółowymi analizami", + "createBtn": "Utwórz krótki URL", + "table": { + "shortUrl": "Krótki URL", + "destination": "Cel", + "status": "Status", + "views": "Wyświetlenia", + "created": "Utworzono" + }, + "create": { + "title": "Utwórz krótki URL", + "destinationLabel": "Docelowy URL", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Własna domena", + "submitBtn": "Utwórz", + "success": "Krótki URL utworzony pomyślnie" + }, + "edit": { + "title": "Edytuj krótki URL", + "submitBtn": "Zaktualizuj", + "success": "Krótki URL zaktualizowany pomyślnie" + }, + "viewDetails": "Zobacz szczegóły", + "delete": { + "title": "Usuń krótki URL", + "confirm": "Czy na pewno chcesz usunąć ten krótki URL? Tej akcji nie można cofnąć.", + "success": "Krótki URL usunięty pomyślnie" + }, + "empty": { + "title": "Brak krótkich URLi", + "description": "Utwórz swój pierwszy krótki URL, aby rozpocząć" + }, + "status": { + "active": "Aktywny", + "inactive": "Wyłączony", + "enable": "Włącz", + "disable": "Wyłącz" + }, + "error": { + "toggleActiveState": { + "title": "Błąd zmiany statusu", + "message": "Wystąpił błąd podczas zmiany statusu. Zostaliśmy powiadomieni i rozwiążemy problem tak szybko, jak to możliwe." + }, + "create": { + "title": "Błąd podczas tworzenia krótkiego URLa", + "message": "Wystąpił błąd podczas tworzenia krótkiego URLa. Spróbuj ponownie." + }, + "update": { + "title": "Błąd podczas aktualizacji krótkiego URLa", + "message": "Wystąpił błąd podczas aktualizacji krótkiego URLa. Spróbuj ponownie." + }, + "delete": { + "title": "Błąd podczas usuwania krótkiego URLa", + "message": "Wystąpił błąd podczas usuwania krótkiego URLa. Spróbuj ponownie." + } + }, + "featureBanner": { + "title": "Przedstawiamy krótkie URLe", + "description": "Teraz możesz tworzyć i zarządzać krótkimi URLami bezpośrednio — skracaj linki, śledź kliknięcia i przeglądaj szczegółowe analizy. Kod QR nie jest potrzebny.", + "cta": "Wypróbuj teraz" + } + }, "general": { "signOut": "Wyloguj się", "proRequired": "Wymagany Plan Pro", @@ -153,6 +218,11 @@ "subHeadline1": "Masz pytanie dotyczące QRcodly lub chcesz podzielić się opinią? Chętnie pomożemy i stale pracujemy nad ulepszeniami.", "emailBtn": "Skontaktuj się" }, + "browserExtensionTeaser": { + "badge": "Wkrótce dostępne", + "headline": "Rozszerzenie Przeglądarki", + "description": "Twórz kody QR w locie — bezpośrednio z przeglądarki. Nasze nadchodzące rozszerzenie pozwoli ci generować i zarządzać kodami QR dla dowolnej strony jednym kliknięciem." + }, "featuresCta": { "headline": "Przegląd kluczowych funkcji", "subHeadline": "Dzięki QRcodly możesz bezpłatnie tworzyć, edytować, zarządzać i analizować kody QR – wszystko w jednym miejscu.", @@ -190,6 +260,11 @@ "headline": "Współpracuj w zespole", "subHeadline": "Zapraszaj członków zespołu i wspólnie pracujcie nad kodami QR. Idealne dla firm, agencji i zespołów." }, + "shortUrlFeature": { + "headline": "Samodzielne krótkie linki", + "subHeadline": "Skracaj, zarządzaj i śledź swoje linki ze szczegółowymi analizami – bez kodu QR.", + "actionLabel": "Wypróbuj teraz" + }, "secureFeature": { "headline": "Twoje dane pozostają u nas", "subHeadline": "QRcodly przechowuje wszystkie dane lokalnie w Niemczech – bez udostępniania zewnętrznym usługom. Clerk używamy wyłącznie do uwierzytelniania." @@ -198,14 +273,6 @@ "scrollRight": "Przewiń w prawo" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Błąd zmiany statusu", - "message": "Wystąpił błąd podczas zmiany statusu. Zostaliśmy powiadomieni i rozwiążemy problem tak szybko, jak to możliwe." - } - } - }, "qrCodeDisabled": { "title": "Kod QR wyłączony", "description": "Niestety, ten kod QR nie jest już aktywny. Cel nie jest już dostępny.", @@ -395,7 +462,6 @@ "domainSelector": { "label": "Domena krótkiego URL", "placeholder": "Wybierz domenę", - "defaultDomain": "Domyślna (qrcodly.de)", "description": "Wybierz, której domeny użyć dla krótkiego URL", "noDomains": "Brak dostępnych domen niestandardowych.", "addDomain": "Dodaj domenę" @@ -1281,6 +1347,14 @@ "bullet2": "Śledź każde skanowanie dzięki analityce w czasie rzeczywistym", "bullet3": "Włączaj lub wyłączaj kody QR natychmiast" }, + "shortUrl": { + "tag": "Nowa Funkcja", + "title": "Samodzielne Krótkie Linki", + "description": "Twórz i zarządzaj krótkimi linkami niezależnie — bez kodu QR. Skracaj dowolne linki, śledź kliknięcia w czasie rzeczywistym i uzyskuj szczegółowe analizy.", + "bullet1": "Skróć dowolny URL jednym kliknięciem", + "bullet2": "Śledź kliknięcia z analizą w czasie rzeczywistym", + "bullet3": "Działa z własnymi domenami dla markowych linków" + }, "analytics": { "tag": "Statystyki", "title": "Szczegółowa Analityka w Czasie Rzeczywistym", diff --git a/apps/frontend/src/dictionaries/ru.json b/apps/frontend/src/dictionaries/ru.json index cd94a40a..f9518508 100644 --- a/apps/frontend/src/dictionaries/ru.json +++ b/apps/frontend/src/dictionaries/ru.json @@ -3,10 +3,75 @@ "nav": { "collection": "Коллекции", "qrCodes": "QR Коды", + "shortUrls": "Короткие ссылки", "templates": "Шаблоны", "tags": "Теги" } }, + "shortUrl": { + "title": "Короткие ссылки", + "description": "Сокращайте, управляйте и отслеживайте ссылки с подробной аналитикой", + "createBtn": "Создать короткую ссылку", + "table": { + "shortUrl": "Короткая ссылка", + "destination": "Назначение", + "status": "Статус", + "views": "Просмотры", + "created": "Создано" + }, + "create": { + "title": "Создать короткую ссылку", + "destinationLabel": "URL назначения", + "destinationPlaceholder": "https://example.com/long-url", + "domainLabel": "Собственный домен", + "submitBtn": "Создать", + "success": "Короткая ссылка успешно создана" + }, + "edit": { + "title": "Редактировать короткую ссылку", + "submitBtn": "Обновить", + "success": "Короткая ссылка успешно обновлена" + }, + "viewDetails": "Подробнее", + "delete": { + "title": "Удалить короткую ссылку", + "confirm": "Вы уверены, что хотите удалить эту короткую ссылку? Это действие нельзя отменить.", + "success": "Короткая ссылка успешно удалена" + }, + "empty": { + "title": "Коротких ссылок пока нет", + "description": "Создайте свою первую короткую ссылку, чтобы начать" + }, + "status": { + "active": "Активный", + "inactive": "Отключено", + "enable": "Включить", + "disable": "Отключить" + }, + "error": { + "toggleActiveState": { + "title": "Ошибка изменения состояния", + "message": "Произошла ошибка при изменении состояния. Мы были уведомлены и исправим проблему как можно скорее." + }, + "create": { + "title": "Ошибка при создании короткой ссылки", + "message": "Произошла ошибка при создании короткой ссылки. Пожалуйста, попробуйте снова." + }, + "update": { + "title": "Ошибка при обновлении короткой ссылки", + "message": "Произошла ошибка при обновлении короткой ссылки. Пожалуйста, попробуйте снова." + }, + "delete": { + "title": "Ошибка при удалении короткой ссылки", + "message": "Произошла ошибка при удалении короткой ссылки. Пожалуйста, попробуйте снова." + } + }, + "featureBanner": { + "title": "Представляем короткие ссылки", + "description": "Теперь вы можете создавать и управлять короткими ссылками напрямую — сокращайте ссылки, отслеживайте клики и просматривайте подробную аналитику. QR-код не нужен.", + "cta": "Попробовать сейчас" + } + }, "general": { "signOut": "Выйти", "proRequired": "Требуется план Pro", @@ -153,6 +218,11 @@ "subHeadline1": "Есть вопрос о QRcodly или хотите поделиться отзывом? Мы с радостью поможем и постоянно работаем над улучшениями.", "emailBtn": "Связаться с нами" }, + "browserExtensionTeaser": { + "badge": "Скоро", + "headline": "Расширение для Браузера", + "description": "Создавайте QR-коды на лету — прямо из браузера. Наше будущее расширение позволит генерировать и управлять QR-кодами для любой страницы одним кликом." + }, "featuresCta": { "headline": "Обзор ключевых функций", "subHeadline": "С QRcodly вы можете бесплатно создавать, редактировать, управлять и анализировать QR-коды – все в одном месте.", @@ -190,6 +260,11 @@ "headline": "Сотрудничество в команде", "subHeadline": "Приглашайте членов команды и работайте вместе над QR-кодами. Идеально подходит для компаний, агентств и команд." }, + "shortUrlFeature": { + "headline": "Самостоятельные короткие ссылки", + "subHeadline": "Сокращайте, управляйте и отслеживайте свои ссылки с подробной аналитикой – без QR-кода.", + "actionLabel": "Попробовать сейчас" + }, "secureFeature": { "headline": "Ваши данные остаются с нами", "subHeadline": "QRcodly хранит все данные локально в Германии – без передачи внешним сервисам. Clerk используется только для аутентификации." @@ -198,14 +273,6 @@ "scrollRight": "Прокрутить вправо" } }, - "shortUrl": { - "error": { - "toggleActiveState": { - "title": "Ошибка изменения состояния", - "message": "Произошла ошибка при изменении состояния. Мы были уведомлены и исправим проблему как можно скорее." - } - } - }, "qrCodeDisabled": { "title": "QR-код отключен", "description": "К сожалению, этот QR-код больше не активен. Место назначения недоступно.", @@ -395,7 +462,6 @@ "domainSelector": { "label": "Домен короткого URL", "placeholder": "Выберите домен", - "defaultDomain": "По умолчанию (qrcodly.de)", "description": "Выберите, какой домен использовать для вашего короткого URL", "noDomains": "Пользовательские домены недоступны.", "addDomain": "Добавить домен" @@ -1281,6 +1347,14 @@ "bullet2": "Отслеживайте каждое сканирование с аналитикой в реальном времени", "bullet3": "Включайте или отключайте QR-коды мгновенно" }, + "shortUrl": { + "tag": "Новая Функция", + "title": "Самостоятельные Короткие Ссылки", + "description": "Создавайте и управляйте короткими ссылками независимо — без QR-кода. Сокращайте любые ссылки, отслеживайте клики в реальном времени и получайте подробную аналитику.", + "bullet1": "Сокращайте любой URL одним кликом", + "bullet2": "Отслеживайте клики с аналитикой в реальном времени", + "bullet3": "Работает с собственными доменами для брендированных ссылок" + }, "analytics": { "tag": "Аналитика", "title": "Подробная Аналитика в Реальном Времени", diff --git a/apps/frontend/src/lib/api/url-shortener.ts b/apps/frontend/src/lib/api/url-shortener.ts index dab0d6a9..57d93a6d 100644 --- a/apps/frontend/src/lib/api/url-shortener.ts +++ b/apps/frontend/src/lib/api/url-shortener.ts @@ -1,7 +1,14 @@ import { useAuth } from '@clerk/nextjs'; -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { apiRequest } from '../utils'; -import type { TAnalyticsResponseDto, TShortUrl } from '@shared/schemas'; +import type { + TAnalyticsResponseDto, + TCreateShortUrlDto, + TShortUrl, + TShortUrlWithCustomDomainPaginatedResponseDto, + TShortUrlWithCustomDomainResponseDto, + TUpdateShortUrlDto, +} from '@shared/schemas'; import { qrCodeQueryKeys } from './qr-code'; // Define query keys @@ -9,6 +16,7 @@ export const urlShortenerQueryKeys = { qrCodeViews: ['qrCodeViews'], shortCodeAnalytics: ['shortCodeAnalytics'], reservedShortUrl: ['reservedShortUrl'], + listShortUrls: ['listShortUrls'], } as const; // Function to delete a configuration template @@ -103,3 +111,116 @@ export function useGetAnalyticsFromShortCodeQuery(shortCode: string) { retry: 2, }); } + +export type ShortUrlFilters = { + search?: string; +}; + +export function useListShortUrlsQuery(page = 1, limit = 10, filters?: ShortUrlFilters) { + const { getToken } = useAuth(); + + return useQuery({ + queryKey: [...urlShortenerQueryKeys.listShortUrls, page, limit, filters], + queryFn: async (): Promise => { + const token = await getToken(); + const queryParams: Record = { page, limit, standalone: true }; + + if (filters?.search) { + queryParams['where[destinationUrl][like]'] = filters.search; + queryParams['where[shortCode][like]'] = filters.search; + } + + return apiRequest( + '/short-url', + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }, + queryParams, + ); + }, + placeholderData: keepPreviousData, + refetchOnWindowFocus: false, + staleTime: 5 * 60 * 1000, + retry: 2, + }); +} + +export function useCreateShortUrlMutation() { + const queryClient = useQueryClient(); + const { getToken } = useAuth(); + + return useMutation({ + mutationFn: async (dto: TCreateShortUrlDto): Promise => { + const token = await getToken(); + return apiRequest('/short-url', { + method: 'POST', + body: JSON.stringify(dto), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + }, + onSuccess: () => { + void queryClient.refetchQueries({ + queryKey: urlShortenerQueryKeys.listShortUrls, + }); + }, + }); +} + +export function useDeleteShortUrlMutation() { + const queryClient = useQueryClient(); + const { getToken } = useAuth(); + + return useMutation({ + mutationFn: async (shortCode: string): Promise => { + const token = await getToken(); + await apiRequest<{ deleted: boolean }>(`/short-url/${shortCode}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + }, + onSuccess: () => { + void queryClient.refetchQueries({ + queryKey: urlShortenerQueryKeys.listShortUrls, + }); + }, + }); +} + +export function useUpdateShortUrlMutation() { + const queryClient = useQueryClient(); + const { getToken } = useAuth(); + + return useMutation({ + mutationFn: async ({ + shortCode, + data, + }: { + shortCode: string; + data: TUpdateShortUrlDto; + }): Promise => { + const token = await getToken(); + return apiRequest(`/short-url/${shortCode}`, { + method: 'PATCH', + body: JSON.stringify(data), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + }); + }, + onSuccess: () => { + void queryClient.refetchQueries({ + queryKey: urlShortenerQueryKeys.listShortUrls, + }); + }, + }); +} diff --git a/apps/frontend/src/lib/utils.ts b/apps/frontend/src/lib/utils.ts index 988e1497..21667ac6 100644 --- a/apps/frontend/src/lib/utils.ts +++ b/apps/frontend/src/lib/utils.ts @@ -15,6 +15,18 @@ export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } +/** + * Extracts the system domain from NEXT_PUBLIC_FRONTEND_URL (e.g., "qrcodly.de" from "https://www.qrcodly.de"). + */ +export function getSystemDomain(): string { + try { + const url = new URL(env.NEXT_PUBLIC_FRONTEND_URL); + return url.hostname.replace(/^www\./, ''); + } catch { + return 'qrcodly.de'; + } +} + /** * Creates a full URL from a short URL object. * Automatically uses the custom domain if set, otherwise falls back to system domain. diff --git a/packages/shared/src/dtos/url-shortener/ShortUrlPaginatedResponseDto.ts b/packages/shared/src/dtos/url-shortener/ShortUrlPaginatedResponseDto.ts new file mode 100644 index 00000000..976fbe07 --- /dev/null +++ b/packages/shared/src/dtos/url-shortener/ShortUrlPaginatedResponseDto.ts @@ -0,0 +1,11 @@ +import { type z } from 'zod'; +import { ShortUrlWithCustomDomainResponseDto } from './ShortUrlResponseDto'; +import { PaginationResponseDtoSchema } from '../PaginationDto'; + +export const ShortUrlWithCustomDomainPaginatedResponseDto = PaginationResponseDtoSchema( + ShortUrlWithCustomDomainResponseDto, +).describe('Short URL With Custom Domain Paginated Response'); + +export type TShortUrlWithCustomDomainPaginatedResponseDto = z.infer< + typeof ShortUrlWithCustomDomainPaginatedResponseDto +>; diff --git a/packages/shared/src/dtos/url-shortener/ShortUrlQueryParamsDto.ts b/packages/shared/src/dtos/url-shortener/ShortUrlQueryParamsDto.ts new file mode 100644 index 00000000..0f2a9f3f --- /dev/null +++ b/packages/shared/src/dtos/url-shortener/ShortUrlQueryParamsDto.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; +import { DefaultStringWhereQueryParamSchema, PaginationQueryParamsSchema } from '../ListRequestDto'; + +const ShortUrlWhereSchema = z.object({ + destinationUrl: DefaultStringWhereQueryParamSchema, + shortCode: DefaultStringWhereQueryParamSchema, +}); + +export const GetShortUrlQueryParamsSchema = PaginationQueryParamsSchema(ShortUrlWhereSchema).extend( + { + standalone: z + .preprocess((val) => { + if (typeof val === 'string') return val === 'true'; + return val; + }, z.boolean()) + .optional(), + }, +); + +export type TGetShortUrlQueryParamsDto = z.infer; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 961997be..90c50fc5 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -16,6 +16,8 @@ export * from './dtos/qr-code-templates/ConfigTemplateRequestParamsDto'; export * from './dtos/url-shortener/ShortUrlRequestParamsDto'; export * from './dtos/url-shortener/ShortUrlResponseDto'; +export * from './dtos/url-shortener/ShortUrlPaginatedResponseDto'; +export * from './dtos/url-shortener/ShortUrlQueryParamsDto'; export * from './dtos/url-shortener/CreateShortUrlDto'; export * from './dtos/url-shortener/UpdateShortUrlDto'; export * from './dtos/url-shortener/AnalyticsResponseDto'; From b499b121ed200645a66a7e7dd23ad2287752e512 Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Tue, 10 Mar 2026 19:06:52 +0100 Subject: [PATCH 22/36] update claude.md --- CLAUDE.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 80323a6a..4d8b98d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -87,6 +87,7 @@ pnpm run studio # Open Drizzle Studio **Module Structure** (`src/modules/`): 7 modules — `qr-code`, `url-shortener`, `billing`, `custom-domain`, `config-template`, `tag`, `analytics-integration` Each module follows this layout: + - `domain/entities/` — Drizzle table definitions + TypeScript types - `domain/repository/` — Data access extending `AbstractRepository` - `useCase/` — Business logic classes (`*.use-case.ts`) @@ -97,6 +98,7 @@ Each module follows this layout: - `setup.ts` — Module registration via `registerRoutes()` **DI Conventions (tsyringe)**: + - Controllers and UseCases: `@injectable()` (transient, new instance per resolution) - Repositories and Services: `@singleton()` (single shared instance) - All deps injected via `@inject(ClassName)` constructor params @@ -106,6 +108,7 @@ Each module follows this layout: **API prefix**: `/api/v1` (e.g., `/api/v1/qr-code`, `/api/v1/short-url`, `/api/v1/billing`) **Core infrastructure** (`src/core/`): + - `db/` — Connection, schema re-exports, migrations. All entity schemas centrally re-exported from `db/schemas/index.ts` - `cache/` — Redis caching (`KeyCache`) - `storage/` — S3/MinIO file uploads @@ -134,6 +137,7 @@ Each module follows this layout: Published as `@shared/schemas`. Import via: `import { ... } from '@shared/schemas'` Uses **Zod v4** (from pnpm catalog). Contains: + - `src/schemas/` — Source-of-truth Zod schemas (e.g., `QrCode.ts` with discriminated union for 8 content types: URL, Text, WiFi, vCard, Email, Location, Event, EPC) - `src/dtos/` — Request/response DTOs derived from schemas via `.pick()`, `.extend()`. Organized by feature domain. - `src/utils/` — QR code content converters, default styling options, deep diff utility @@ -145,6 +149,7 @@ Build: `tsc` → `dist/` (CommonJS). Must be built before backend/frontend (`pnp ## Local Development Environment Docker Compose provides: + - MySQL (3306) — root/root, database: qrcodly - Redis (6379) - MinIO S3 mock (9000 API, 9001 console) — minio/testtest @@ -154,6 +159,7 @@ Docker Compose provides: ## Testing Patterns Backend tests use Jest with: + - Global setup/teardown for test database isolation - `--runInBand` for sequential execution - 30 second timeout per test From 63e1845083caf6b854acf92f0910fb1df82a3a23 Mon Sep 17 00:00:00 2001 From: Florian Breuer Date: Wed, 11 Mar 2026 23:25:13 +0100 Subject: [PATCH 23/36] feat: add product pages, navigation dropdown, and UI refinements Add dedicated product pages for URL Shortener, QR Codes, and Analytics with hero sections, feature details, mockups, FAQs, and CTAs. Update header with products dropdown menu, footer with product links, and sitemap. Refine short URL dashboard, fix Zod v4 import in ApiError, add stats bar to home page, and update all 8 locale dictionaries. Co-Authored-By: Claude Opus 4.6 --- apps/frontend/src/app/[locale]/page.tsx | 10 + apps/frontend/src/app/[locale]/plans/page.tsx | 5 +- .../app/[locale]/products/analytics/page.tsx | 210 +++++ .../app/[locale]/products/qr-codes/page.tsx | 221 +++++ .../[locale]/products/url-shortener/page.tsx | 212 +++++ apps/frontend/src/app/sitemap.ts | 3 + .../src/components/BrowserExtensionTeaser.tsx | 5 +- apps/frontend/src/components/Cta.tsx | 5 +- apps/frontend/src/components/CtaSection.tsx | 5 +- apps/frontend/src/components/Faq.tsx | 5 +- apps/frontend/src/components/FeaturesPage.tsx | 65 +- apps/frontend/src/components/Footer.tsx | 107 ++- apps/frontend/src/components/Header.tsx | 211 ++++- apps/frontend/src/components/Hero.tsx | 5 +- apps/frontend/src/components/LanguageNav.tsx | 166 +++- .../shortUrl/CreateShortUrlDialog.tsx | 4 +- .../dashboard/shortUrl/EditShortUrlDialog.tsx | 8 +- .../shortUrl/ShortUrlDetailContent.tsx | 20 +- .../dashboard/shortUrl/ShortUrlList.tsx | 11 +- .../dashboard/shortUrl/ShortUrlListItem.tsx | 2 +- .../components/products/CrossProductCards.tsx | 56 ++ .../components/products/ProductCtaSection.tsx | 41 + .../components/products/ProductFaqSection.tsx | 61 ++ .../products/ProductFeatureSection.tsx | 81 ++ .../products/ProductHeroSection.tsx | 37 + .../components/products/ProductStatsBar.tsx | 41 + .../components/products/ProductUseCases.tsx | 45 + .../products/mockups/AnalyticsMockups.tsx | 469 ++++++++++ .../products/mockups/QrCodesMockups.tsx | 856 ++++++++++++++++++ .../products/mockups/UrlShortenerMockups.tsx | 355 ++++++++ apps/frontend/src/components/ui/badge.tsx | 1 + apps/frontend/src/components/ui/button.tsx | 5 +- apps/frontend/src/components/ui/heading.tsx | 35 + apps/frontend/src/dictionaries/de.json | 395 +++++++- apps/frontend/src/dictionaries/en.json | 393 +++++++- apps/frontend/src/dictionaries/es.json | 389 +++++++- apps/frontend/src/dictionaries/fr.json | 389 +++++++- apps/frontend/src/dictionaries/it.json | 389 +++++++- apps/frontend/src/dictionaries/nl.json | 389 +++++++- apps/frontend/src/dictionaries/pl.json | 395 +++++++- apps/frontend/src/dictionaries/ru.json | 445 ++++++++- apps/frontend/src/lib/api/ApiError.ts | 6 +- apps/frontend/src/lib/api/url-shortener.ts | 3 + apps/frontend/src/lib/utils.ts | 4 +- 44 files changed, 6251 insertions(+), 309 deletions(-) create mode 100644 apps/frontend/src/app/[locale]/products/analytics/page.tsx create mode 100644 apps/frontend/src/app/[locale]/products/qr-codes/page.tsx create mode 100644 apps/frontend/src/app/[locale]/products/url-shortener/page.tsx create mode 100644 apps/frontend/src/components/products/CrossProductCards.tsx create mode 100644 apps/frontend/src/components/products/ProductCtaSection.tsx create mode 100644 apps/frontend/src/components/products/ProductFaqSection.tsx create mode 100644 apps/frontend/src/components/products/ProductFeatureSection.tsx create mode 100644 apps/frontend/src/components/products/ProductHeroSection.tsx create mode 100644 apps/frontend/src/components/products/ProductStatsBar.tsx create mode 100644 apps/frontend/src/components/products/ProductUseCases.tsx create mode 100644 apps/frontend/src/components/products/mockups/AnalyticsMockups.tsx create mode 100644 apps/frontend/src/components/products/mockups/QrCodesMockups.tsx create mode 100644 apps/frontend/src/components/products/mockups/UrlShortenerMockups.tsx create mode 100644 apps/frontend/src/components/ui/heading.tsx diff --git a/apps/frontend/src/app/[locale]/page.tsx b/apps/frontend/src/app/[locale]/page.tsx index d2fb93d5..288366fa 100644 --- a/apps/frontend/src/app/[locale]/page.tsx +++ b/apps/frontend/src/app/[locale]/page.tsx @@ -11,6 +11,7 @@ import { auth } from '@clerk/nextjs/server'; import { notFound } from 'next/navigation'; import { SUPPORTED_LANGUAGES } from '@/i18n/routing'; import { Hero } from '@/components/Hero'; +import { ProductStatsBar } from '@/components/products/ProductStatsBar'; import dynamic from 'next/dynamic'; // Dynamic imports for below-the-fold components to reduce initial bundle size @@ -40,6 +41,7 @@ export default async function Page({ params }: DefaultPageParams) { } const tMeta = await getTranslations({ locale, namespace: 'metadata' }); + const tStats = await getTranslations({ locale, namespace: 'homeStats' }); const { userId } = await auth(); const isSignedIn = !!userId; @@ -107,6 +109,14 @@ export default async function Page({ params }: DefaultPageParams) { + {/* Stats Bar */} + ({ + value: tStats(`stat${i + 1}Value`), + label: tStats(`stat${i + 1}Label`), + }))} + /> + {/* Features Slider */}
diff --git a/apps/frontend/src/app/[locale]/plans/page.tsx b/apps/frontend/src/app/[locale]/plans/page.tsx index e5ef1445..26b531e1 100644 --- a/apps/frontend/src/app/[locale]/plans/page.tsx +++ b/apps/frontend/src/app/[locale]/plans/page.tsx @@ -3,6 +3,7 @@ import Footer from '@/components/Footer'; import Header from '@/components/Header'; import { PricingCard } from '@/components/plans/PricingCard'; import Container from '@/components/ui/container'; +import { Heading } from '@/components/ui/heading'; import { env } from '@/env'; import { routing, SUPPORTED_LANGUAGES } from '@/i18n/routing'; import type { DefaultPageParams } from '@/types/page'; @@ -63,9 +64,9 @@ export default async function Page({ params }: DefaultPageParams) {
-

+ {t('title')} -

+

{t('subtitle')}

diff --git a/apps/frontend/src/app/[locale]/products/analytics/page.tsx b/apps/frontend/src/app/[locale]/products/analytics/page.tsx new file mode 100644 index 00000000..f21d52d3 --- /dev/null +++ b/apps/frontend/src/app/[locale]/products/analytics/page.tsx @@ -0,0 +1,210 @@ +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; +import { ProductHeroSection } from '@/components/products/ProductHeroSection'; +import { ProductFeatureSection } from '@/components/products/ProductFeatureSection'; + +import { ProductUseCases } from '@/components/products/ProductUseCases'; +import { CrossProductCards } from '@/components/products/CrossProductCards'; +import { ProductFaqSection } from '@/components/products/ProductFaqSection'; +import { + RealTimeMetricsMockup, + ChannelComparisonMockup, + IntegrationsDashboardMockup, + GeographicInsightsMockup, + ExportReportingMockup, + PrivacyFirstMockup, +} from '@/components/products/mockups/AnalyticsMockups'; +import { + LinkIcon, + QrCodeIcon, + AdjustmentsHorizontalIcon, + MapPinIcon, + DevicePhoneMobileIcon, + DocumentChartBarIcon, + ArrowsRightLeftIcon, + ArrowsPointingInIcon, +} from '@heroicons/react/24/outline'; +import type { DefaultPageParams } from '@/types/page'; +import { getTranslations } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { routing, SUPPORTED_LANGUAGES } from '@/i18n/routing'; +import type { Metadata } from 'next'; +import { env } from '@/env'; + +const PAGE_PATH = 'products/analytics'; + +export async function generateMetadata({ params }: DefaultPageParams): Promise { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) return {}; + const t = await getTranslations({ locale, namespace: 'productsAnalytics' }); + const baseUrl = env.NEXT_PUBLIC_FRONTEND_URL; + + return { + title: t('metaTitle'), + description: t('metaDescription'), + alternates: { + canonical: + locale === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${locale}/${PAGE_PATH}`, + languages: { + 'x-default': `${baseUrl}/${PAGE_PATH}`, + ...Object.fromEntries( + routing.locales + .filter((l) => l !== locale) + .map((l) => [ + l, + l === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${l}/${PAGE_PATH}`, + ]), + ), + }, + }, + }; +} + +export default async function Page({ params }: DefaultPageParams) { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) notFound(); + + const t = await getTranslations({ locale, namespace: 'productsAnalytics' }); + + const features = [ + { + title: t('features.realTime.title'), + description: t('features.realTime.description'), + bullets: [ + t('features.realTime.bullet1'), + t('features.realTime.bullet2'), + t('features.realTime.bullet3'), + ], + visual: , + }, + { + title: t('features.channels.title'), + comingSoon: t('features.channels.comingSoon'), + description: t('features.channels.description'), + bullets: [ + t('features.channels.bullet1'), + t('features.channels.bullet2'), + t('features.channels.bullet3'), + ], + visual: , + }, + { + title: t('features.integrations.title'), + description: t('features.integrations.description'), + bullets: [ + t('features.integrations.bullet1'), + t('features.integrations.bullet2'), + t('features.integrations.bullet3'), + ], + visual: , + }, + { + title: t('features.geographic.title'), + description: t('features.geographic.description'), + bullets: [ + t('features.geographic.bullet1'), + t('features.geographic.bullet2'), + t('features.geographic.bullet3'), + ], + visual: , + }, + { + title: t('features.exportReporting.title'), + comingSoon: t('features.exportReporting.comingSoon'), + description: t('features.exportReporting.description'), + bullets: [ + t('features.exportReporting.bullet1'), + t('features.exportReporting.bullet2'), + t('features.exportReporting.bullet3'), + ], + visual: , + }, + { + title: t('features.privacy.title'), + description: t('features.privacy.description'), + bullets: [ + t('features.privacy.bullet1'), + t('features.privacy.bullet2'), + t('features.privacy.bullet3'), + ], + visual: , + }, + ]; + + const useCaseIcons = [ + , + , + , + , + , + , + ]; + + const useCases = Array.from({ length: 6 }, (_, i) => ({ + icon: useCaseIcons[i], + title: t(`useCases.case${i + 1}Title`), + description: t(`useCases.case${i + 1}Description`), + })); + + const faqItems = Array.from({ length: 6 }, (_, i) => ({ + question: t(`faq.q${i + 1}`), + answer: t(`faq.a${i + 1}`), + })); + + return ( + <> +
+
+ + + {features.map((feature, i) => ( + + ))} + + + + , + }, + { + title: t('crossProducts.qrCodes.title'), + description: t('crossProducts.qrCodes.description'), + href: '/products/qr-codes', + icon: , + }, + ]} + /> + + +
+
+ + ); +} diff --git a/apps/frontend/src/app/[locale]/products/qr-codes/page.tsx b/apps/frontend/src/app/[locale]/products/qr-codes/page.tsx new file mode 100644 index 00000000..0858751f --- /dev/null +++ b/apps/frontend/src/app/[locale]/products/qr-codes/page.tsx @@ -0,0 +1,221 @@ +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; +import { ProductHeroSection } from '@/components/products/ProductHeroSection'; +import { ProductFeatureSection } from '@/components/products/ProductFeatureSection'; + +import { ProductUseCases } from '@/components/products/ProductUseCases'; +import { CrossProductCards } from '@/components/products/CrossProductCards'; +import { ProductFaqSection } from '@/components/products/ProductFaqSection'; +import { ProductCtaSection } from '@/components/products/ProductCtaSection'; +import { + QrCustomizationMockup, + QrScanAnalyticsMockup, + QrContentTypesMockup, + QrDynamicUpdatesMockup, + QrBulkTemplatesMockup, + QrApiAccessMockup, +} from '@/components/products/mockups/QrCodesMockups'; +import { + LinkIcon, + ChartBarIcon, + BuildingStorefrontIcon, + ShoppingBagIcon, + TicketIcon, + HomeModernIcon, + AcademicCapIcon, + MegaphoneIcon, +} from '@heroicons/react/24/outline'; +import type { DefaultPageParams } from '@/types/page'; +import { getTranslations } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { routing, SUPPORTED_LANGUAGES } from '@/i18n/routing'; +import type { Metadata } from 'next'; +import { env } from '@/env'; + +const PAGE_PATH = 'products/qr-codes'; + +export async function generateMetadata({ params }: DefaultPageParams): Promise { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) return {}; + const t = await getTranslations({ locale, namespace: 'productsQrCodes' }); + const baseUrl = env.NEXT_PUBLIC_FRONTEND_URL; + + return { + title: t('metaTitle'), + description: t('metaDescription'), + alternates: { + canonical: + locale === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${locale}/${PAGE_PATH}`, + languages: { + 'x-default': `${baseUrl}/${PAGE_PATH}`, + ...Object.fromEntries( + routing.locales + .filter((l) => l !== locale) + .map((l) => [ + l, + l === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${l}/${PAGE_PATH}`, + ]), + ), + }, + }, + }; +} + +export default async function Page({ params }: DefaultPageParams) { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) notFound(); + + const t = await getTranslations({ locale, namespace: 'productsQrCodes' }); + + const features = [ + { + title: t('features.customization.title'), + description: t('features.customization.description'), + bullets: [ + t('features.customization.bullet1'), + t('features.customization.bullet2'), + t('features.customization.bullet3'), + ], + visual: , + }, + { + title: t('features.analytics.title'), + description: t('features.analytics.description'), + bullets: [ + t('features.analytics.bullet1'), + t('features.analytics.bullet2'), + t('features.analytics.bullet3'), + ], + visual: , + }, + { + title: t('features.contentTypes.title'), + description: t('features.contentTypes.description'), + bullets: [ + t('features.contentTypes.bullet1'), + t('features.contentTypes.bullet2'), + t('features.contentTypes.bullet3'), + ], + visual: , + }, + { + title: t('features.dynamicUpdates.title'), + description: t('features.dynamicUpdates.description'), + bullets: [ + t('features.dynamicUpdates.bullet1'), + t('features.dynamicUpdates.bullet2'), + t('features.dynamicUpdates.bullet3'), + ], + visual: , + }, + { + title: t('features.bulkTemplates.title'), + description: t('features.bulkTemplates.description'), + bullets: [ + t('features.bulkTemplates.bullet1'), + t('features.bulkTemplates.bullet2'), + t('features.bulkTemplates.bullet3'), + ], + visual: , + }, + { + title: t('features.apiAccess.title'), + description: t('features.apiAccess.description'), + bullets: [ + t('features.apiAccess.bullet1'), + t('features.apiAccess.bullet2'), + t('features.apiAccess.bullet3'), + ], + visual: , + actionButton: { + label: t('features.apiAccess.actionLabel'), + href: '/docs/api', + external: true, + }, + }, + ]; + + const useCaseIcons = [ + , + , + , + , + , + , + ]; + + const useCases = Array.from({ length: 6 }, (_, i) => ({ + icon: useCaseIcons[i], + title: t(`useCases.case${i + 1}Title`), + description: t(`useCases.case${i + 1}Description`), + })); + + const faqItems = Array.from({ length: 6 }, (_, i) => ({ + question: t(`faq.q${i + 1}`), + answer: t(`faq.a${i + 1}`), + })); + + return ( + <> +
+
+ + + {features.map((feature, i) => ( + + ))} + + + + , + }, + { + title: t('crossProducts.analytics.title'), + description: t('crossProducts.analytics.description'), + href: '/products/analytics', + icon: , + }, + ]} + /> + + + + +
+
+ + ); +} diff --git a/apps/frontend/src/app/[locale]/products/url-shortener/page.tsx b/apps/frontend/src/app/[locale]/products/url-shortener/page.tsx new file mode 100644 index 00000000..8ab225d0 --- /dev/null +++ b/apps/frontend/src/app/[locale]/products/url-shortener/page.tsx @@ -0,0 +1,212 @@ +import Header from '@/components/Header'; +import Footer from '@/components/Footer'; +import { ProductHeroSection } from '@/components/products/ProductHeroSection'; +import { ProductFeatureSection } from '@/components/products/ProductFeatureSection'; + +import { ProductUseCases } from '@/components/products/ProductUseCases'; +import { CrossProductCards } from '@/components/products/CrossProductCards'; +import { ProductFaqSection } from '@/components/products/ProductFaqSection'; +import { ProductCtaSection } from '@/components/products/ProductCtaSection'; +import { + BrandedLinksMockup, + LinkAnalyticsMockup, + LinkManagementMockup, + BulkOperationsMockup, + ApiAccessMockup, +} from '@/components/products/mockups/UrlShortenerMockups'; +import { + QrCodeIcon, + ChartBarIcon, + MegaphoneIcon, + ChatBubbleBottomCenterTextIcon, + CurrencyDollarIcon, + ShoppingCartIcon, + CodeBracketIcon, + BuildingOffice2Icon, +} from '@heroicons/react/24/outline'; +import type { DefaultPageParams } from '@/types/page'; +import { getTranslations } from 'next-intl/server'; +import { notFound } from 'next/navigation'; +import { routing, SUPPORTED_LANGUAGES } from '@/i18n/routing'; +import type { Metadata } from 'next'; +import { env } from '@/env'; + +const PAGE_PATH = 'products/url-shortener'; + +export async function generateMetadata({ params }: DefaultPageParams): Promise { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) return {}; + const t = await getTranslations({ locale, namespace: 'productsUrlShortener' }); + const baseUrl = env.NEXT_PUBLIC_FRONTEND_URL; + + return { + title: t('metaTitle'), + description: t('metaDescription'), + alternates: { + canonical: + locale === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${locale}/${PAGE_PATH}`, + languages: { + 'x-default': `${baseUrl}/${PAGE_PATH}`, + ...Object.fromEntries( + routing.locales + .filter((l) => l !== locale) + .map((l) => [ + l, + l === routing.defaultLocale + ? `${baseUrl}/${PAGE_PATH}` + : `${baseUrl}/${l}/${PAGE_PATH}`, + ]), + ), + }, + }, + }; +} + +export default async function Page({ params }: DefaultPageParams) { + const { locale } = await params; + if (!SUPPORTED_LANGUAGES.includes(locale)) notFound(); + + const t = await getTranslations({ locale, namespace: 'productsUrlShortener' }); + + const features = [ + { + title: t('features.brandedLinks.title'), + description: t('features.brandedLinks.description'), + bullets: [ + t('features.brandedLinks.bullet1'), + t('features.brandedLinks.bullet2'), + t('features.brandedLinks.bullet3'), + ], + visual: , + }, + { + title: t('features.analytics.title'), + description: t('features.analytics.description'), + bullets: [ + t('features.analytics.bullet1'), + t('features.analytics.bullet2'), + t('features.analytics.bullet3'), + ], + visual: , + }, + { + title: t('features.management.title'), + description: t('features.management.description'), + bullets: [ + t('features.management.bullet1'), + t('features.management.bullet2'), + t('features.management.bullet3'), + ], + visual: , + }, + { + title: t('features.bulkOperations.title'), + comingSoon: t('features.bulkOperations.comingSoon'), + description: t('features.bulkOperations.description'), + bullets: [ + t('features.bulkOperations.bullet1'), + t('features.bulkOperations.bullet2'), + t('features.bulkOperations.bullet3'), + ], + visual: , + }, + { + title: t('features.apiAccess.title'), + description: t('features.apiAccess.description'), + bullets: [ + t('features.apiAccess.bullet1'), + t('features.apiAccess.bullet2'), + t('features.apiAccess.bullet3'), + ], + visual: , + actionButton: { + label: t('features.apiAccess.actionLabel'), + href: '/docs/api', + external: true, + }, + }, + ]; + + const useCaseIcons = [ + , + , + , + , + , + , + ]; + + const useCases = Array.from({ length: 6 }, (_, i) => ({ + icon: useCaseIcons[i], + title: t(`useCases.case${i + 1}Title`), + description: t(`useCases.case${i + 1}Description`), + })); + + const faqItems = Array.from({ length: 6 }, (_, i) => ({ + question: t(`faq.q${i + 1}`), + answer: t(`faq.a${i + 1}`), + })); + + return ( + <> +
+
+ + + {features.map((feature, i) => ( + + ))} + + + + , + }, + { + title: t('crossProducts.analytics.title'), + description: t('crossProducts.analytics.description'), + href: '/products/analytics', + icon: , + }, + ]} + /> + + + + +
+
+ + ); +} diff --git a/apps/frontend/src/app/sitemap.ts b/apps/frontend/src/app/sitemap.ts index 68400f03..d4c1ad3a 100644 --- a/apps/frontend/src/app/sitemap.ts +++ b/apps/frontend/src/app/sitemap.ts @@ -7,6 +7,9 @@ const PAGES = [ 'docs', 'plans', 'features', + 'products/url-shortener', + 'products/qr-codes', + 'products/analytics', 'imprint', 'privacy-policy', ]; diff --git a/apps/frontend/src/components/BrowserExtensionTeaser.tsx b/apps/frontend/src/components/BrowserExtensionTeaser.tsx index 9fd891a4..44b1719e 100644 --- a/apps/frontend/src/components/BrowserExtensionTeaser.tsx +++ b/apps/frontend/src/components/BrowserExtensionTeaser.tsx @@ -1,6 +1,7 @@ 'use client'; import { PuzzlePieceIcon } from '@heroicons/react/24/outline'; +import { Heading } from '@/components/ui/heading'; import { useTranslations } from 'next-intl'; import { motion } from 'framer-motion'; import Container from './ui/container'; @@ -31,9 +32,9 @@ export function BrowserExtensionTeaser() { {t('badge')} -

+ {t('headline')} -

+

{t('description')}

diff --git a/apps/frontend/src/components/Cta.tsx b/apps/frontend/src/components/Cta.tsx index 7d9309b5..a14338f8 100644 --- a/apps/frontend/src/components/Cta.tsx +++ b/apps/frontend/src/components/Cta.tsx @@ -1,6 +1,7 @@ 'use client'; import { buttonVariants } from './ui/button'; +import { Heading } from '@/components/ui/heading'; import { EnvelopeIcon } from '@heroicons/react/24/outline'; import { useTranslations } from 'next-intl'; import { motion } from 'framer-motion'; @@ -20,9 +21,9 @@ export function Cta() { transition={{ duration: 0.6 }} >
-

+ {t('headline')} -

+

{t('subHeadline1')}

diff --git a/apps/frontend/src/components/CtaSection.tsx b/apps/frontend/src/components/CtaSection.tsx index 3263202b..64119a0e 100644 --- a/apps/frontend/src/components/CtaSection.tsx +++ b/apps/frontend/src/components/CtaSection.tsx @@ -1,5 +1,6 @@ import { buttonVariants } from '@/components/ui/button'; import Container from '@/components/ui/container'; +import { Heading } from '@/components/ui/heading'; import { Link } from '@/i18n/navigation'; import { getTranslations } from 'next-intl/server'; import { AnimateOnScroll } from './features/AnimateOnScroll'; @@ -13,9 +14,9 @@ export async function CtaSection() {
-

+ {t('cta.title')} -

+

{t('cta.subtitle')}

diff --git a/apps/frontend/src/components/Faq.tsx b/apps/frontend/src/components/Faq.tsx index bfbab394..f407d150 100644 --- a/apps/frontend/src/components/Faq.tsx +++ b/apps/frontend/src/components/Faq.tsx @@ -4,6 +4,7 @@ import { AccordionItem, AccordionTrigger, } from '@/components/ui/accordion'; +import { Heading } from '@/components/ui/heading'; import { useTranslations } from 'next-intl'; import Container from './ui/container'; @@ -18,9 +19,9 @@ export default function FAQSection() {
-

+ {t('headline')} -

+ {faqItems.map((item, index) => ( - {/* Text side */} -

+ {title} -

+

{description}

@@ -62,7 +58,6 @@ function FeatureDetailSection({
- {/* Visual side */} , - }, - { - title: t('spotlight.analytics.title'), - description: t('spotlight.analytics.description'), - bullets: [ - t('spotlight.analytics.bullet1'), - t('spotlight.analytics.bullet2'), - t('spotlight.analytics.bullet3'), - ], - visual: , - }, - { - title: t('spotlight.collection.title'), - description: t('spotlight.collection.description'), - bullets: [ - t('spotlight.collection.bullet1'), - t('spotlight.collection.bullet2'), - t('spotlight.collection.bullet3'), - ], - visual: , - }, { title: t('spotlight.templates.title'), description: t('spotlight.templates.description'), @@ -182,16 +147,6 @@ export async function FeaturesPage({ locale }: { locale: string }) { ], visual: , }, - { - title: t('spotlight.shortUrl.title'), - description: t('spotlight.shortUrl.description'), - bullets: [ - t('spotlight.shortUrl.bullet1'), - t('spotlight.shortUrl.bullet2'), - t('spotlight.shortUrl.bullet3'), - ], - visual: , - }, { title: t('spotlight.teams.title'), description: t('spotlight.teams.description'), @@ -210,9 +165,9 @@ export async function FeaturesPage({ locale }: { locale: string }) {
-

+ {t('title')} -

+
diff --git a/apps/frontend/src/components/Footer.tsx b/apps/frontend/src/components/Footer.tsx index 8c76e02e..2c8553f7 100644 --- a/apps/frontend/src/components/Footer.tsx +++ b/apps/frontend/src/components/Footer.tsx @@ -48,8 +48,8 @@ export default function Footer() { {/* Product */}
-

{t('product')}

-
    +

    {t('product')}

    +
    • +
    • + + {t('urlShortener')} + +
    • +
    • + + {t('qrCodes')} + +
    • +
    • + + {t('analyticsProduct')} + +
{/* Resources */}
-

{t('resources')}

-
    +

    {t('resources')}

    +
    • {/* Legal */} - diff --git a/apps/frontend/src/components/Header.tsx b/apps/frontend/src/components/Header.tsx index f9e9cc98..37d2ad26 100644 --- a/apps/frontend/src/components/Header.tsx +++ b/apps/frontend/src/components/Header.tsx @@ -6,12 +6,17 @@ import { Button, buttonVariants } from './ui/button'; import { useTranslations } from 'next-intl'; import { Link, usePathname } from '@/i18n/navigation'; import { LanguageNav } from './LanguageNav'; -import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/solid'; -import { useState } from 'react'; +import { Bars3Icon, XMarkIcon, ChevronDownIcon } from '@heroicons/react/24/solid'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Drawer, DrawerClose, DrawerContent, DrawerHeader, DrawerTitle } from './ui/drawer'; -import { RectangleStackIcon } from '@heroicons/react/24/outline'; +import { + RectangleStackIcon, + LinkIcon, + QrCodeIcon, + ChartBarIcon, +} from '@heroicons/react/24/outline'; import { cn } from '@/lib/utils'; -import { motion } from 'framer-motion'; +import { motion, AnimatePresence } from 'framer-motion'; import { QrcodlyLogo } from './QrcodlyLogo'; const containerVariants = { @@ -44,14 +49,84 @@ export default function Header({ const t = useTranslations('header'); const pathname = usePathname(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); + const [productsOpen, setProductsOpen] = useState(false); + const [scrolled, setScrolled] = useState(false); + const closeTimeout = useRef | null>(null); + const dropdownRef = useRef(null); + + useEffect(() => { + const handleScroll = () => setScrolled(window.scrollY > 20); + handleScroll(); + window.addEventListener('scroll', handleScroll, { passive: true }); + return () => window.removeEventListener('scroll', handleScroll); + }, []); - const isDocsActive = pathname.startsWith('/docs'); const isFeaturesActive = pathname === '/features'; const isPlansActive = pathname === '/plans'; + const isProductsActive = pathname.startsWith('/products'); + + const openProducts = useCallback(() => { + if (closeTimeout.current) { + clearTimeout(closeTimeout.current); + closeTimeout.current = null; + } + setProductsOpen(true); + }, []); + + const closeProducts = useCallback(() => { + closeTimeout.current = setTimeout(() => setProductsOpen(false), 200); + }, []); + + // Close dropdown on route change + useEffect(() => { + setProductsOpen(false); + }, [pathname]); + + // Close dropdown on click outside + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setProductsOpen(false); + } + } + if (productsOpen) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + return undefined; + }, [productsOpen]); + + const productLinks = [ + { + href: '/products/url-shortener' as const, + title: t('urlShortenerLink'), + description: t('urlShortenerDesc'), + icon: , + }, + { + href: '/products/qr-codes' as const, + title: t('qrCodesLink'), + description: t('qrCodesDesc'), + icon: , + }, + { + href: '/products/analytics' as const, + title: t('analyticsLink'), + description: t('analyticsDesc'), + icon: , + }, + ]; return ( -
      - +
      +
      {!hideLogo && ( @@ -61,6 +136,79 @@ export default function Header({ )}
      + {/* Products dropdown */} +
      + + + + {productsOpen && ( + + {/* Invisible bridge to prevent gap hover loss */} +
      +
      +
      + {productLinks.map((link) => { + const isActive = pathname.startsWith(link.href); + return ( + +
      + {link.icon} +
      +
      +
      + {link.title} +
      +
      + {link.description} +
      +
      + + ); + })} +
      +
      + + )} + +
      {t('plansBtn')} - - Docs - @@ -156,6 +293,28 @@ export default function Header({ initial="hidden" animate={mobileMenuOpen ? 'visible' : 'hidden'} > + +
      + {t('productsBtn')} +
      +
      + {productLinks.map((link) => ( + + + {link.icon} + {link.title} + + + ))} - - - Docs - - { {t('badge')} -

      + {t('title')} -

      +

      {t('subtitle')}

      diff --git a/apps/frontend/src/components/LanguageNav.tsx b/apps/frontend/src/components/LanguageNav.tsx index 9ea7069f..0f5455d1 100644 --- a/apps/frontend/src/components/LanguageNav.tsx +++ b/apps/frontend/src/components/LanguageNav.tsx @@ -1,56 +1,144 @@ 'use client'; -import React from 'react'; +import React, { useCallback, useRef, useState, useEffect } from 'react'; import { useLocale } from 'next-intl'; import { Link, usePathname } from '@/i18n/navigation'; import { SUPPORTED_LANGUAGES } from '@/i18n/routing'; -import { GlobeAsiaAustraliaIcon } from '@heroicons/react/24/outline'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from './ui/dropdown-menu'; +import { ChevronDownIcon } from '@heroicons/react/24/solid'; +import { GlobeAltIcon } from '@heroicons/react/24/outline'; import { cn } from '@/lib/utils'; +import { motion, AnimatePresence } from 'framer-motion'; + +const LANGUAGE_NAMES: Record = { + de: 'Deutsch', + en: 'English', + es: 'Español', + fr: 'Français', + it: 'Italiano', + nl: 'Nederlands', + pl: 'Polski', + ru: 'Русский', +}; export const LanguageNav = () => { const locale = useLocale(); const currentPath = usePathname(); - const languageLinks = SUPPORTED_LANGUAGES.map((lang) => { - return { - lang, - path: currentPath.replace(`/${locale}`, ``), - }; - }).sort((a, b) => a.lang.localeCompare(b.lang)); + const [open, setOpen] = useState(false); + const closeTimeout = useRef | null>(null); + const containerRef = useRef(null); + + const languageLinks = SUPPORTED_LANGUAGES.map((lang) => ({ + lang, + path: currentPath.replace(`/${locale}`, ''), + })).sort((a, b) => a.lang.localeCompare(b.lang)); + + const openMenu = useCallback(() => { + if (closeTimeout.current) { + clearTimeout(closeTimeout.current); + closeTimeout.current = null; + } + setOpen(true); + }, []); + + const closeMenu = useCallback(() => { + closeTimeout.current = setTimeout(() => setOpen(false), 200); + }, []); + + useEffect(() => { + setOpen(false); + }, [currentPath]); + + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setOpen(false); + } + } + if (open) { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + return undefined; + }, [open]); return (
      -
      - - - - - - {languageLinks.map((link) => ( - - - {link.lang.toUpperCase()} - - - ))} - - + {/* Desktop */} +
      + + + + {open && ( + + {/* Invisible bridge */} +
      +
      + {languageLinks.map((link, idx) => { + const isActive = locale === link.lang; + return ( + + {idx > 0 &&
      } + + + {LANGUAGE_NAMES[link.lang] ?? link.lang} + + + {link.lang.toUpperCase()} + + + + ); + })} +
      + + )} +
      + + {/* Mobile */}
      {languageLinks.map((link) => ( { - if (defaultDomain?.id) { + if (defaultDomain?.id && !form.getValues('customDomainId')) { form.setValue('customDomainId', defaultDomain.id); } }, [defaultDomain, form]); diff --git a/apps/frontend/src/components/dashboard/shortUrl/EditShortUrlDialog.tsx b/apps/frontend/src/components/dashboard/shortUrl/EditShortUrlDialog.tsx index ef3457b6..4f4ec6e6 100644 --- a/apps/frontend/src/components/dashboard/shortUrl/EditShortUrlDialog.tsx +++ b/apps/frontend/src/components/dashboard/shortUrl/EditShortUrlDialog.tsx @@ -3,7 +3,7 @@ import { useEffect } from 'react'; import { useTranslations } from 'next-intl'; import { useForm } from 'react-hook-form'; -import { z } from 'zod/v3'; +import { z } from 'zod'; import { zodResolver } from '@hookform/resolvers/zod'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; @@ -50,11 +50,12 @@ export function EditShortUrlDialog({ shortUrl, open, onOpenChange }: EditShortUr }); useEffect(() => { + if (!open) return; form.reset({ destinationUrl: shortUrl.destinationUrl ?? '', customDomainId: shortUrl.customDomain?.id ?? null, }); - }, [form, shortUrl]); + }, [form, shortUrl, open]); const onSubmit = async (data: EditShortUrlForm) => { try { @@ -67,7 +68,8 @@ export function EditShortUrlDialog({ shortUrl, open, onOpenChange }: EditShortUr }); posthog.capture('short-url-updated', { shortCode: shortUrl.shortCode, - destinationUrl: data.destinationUrl, + destinationChanged: data.destinationUrl !== shortUrl.destinationUrl, + hasCustomDomain: Boolean(data.customDomainId), }); toast({ title: t('edit.success') }); onOpenChange(false); diff --git a/apps/frontend/src/components/dashboard/shortUrl/ShortUrlDetailContent.tsx b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlDetailContent.tsx index 1fd870ea..32629de7 100644 --- a/apps/frontend/src/components/dashboard/shortUrl/ShortUrlDetailContent.tsx +++ b/apps/frontend/src/components/dashboard/shortUrl/ShortUrlDetailContent.tsx @@ -2,8 +2,7 @@ import React, { useCallback } from 'react'; import { useTranslations } from 'next-intl'; -import { useLocale } from 'next-intl'; -import { useRouter } from 'next/navigation'; +import { useRouter } from '@/i18n/navigation'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card, CardContent } from '@/components/ui/card'; @@ -37,8 +36,7 @@ import { import { createLinkFromShortUrl } from '@/lib/utils'; import { EditShortUrlDialog } from './EditShortUrlDialog'; import type { TShortUrlWithCustomDomainResponseDto } from '@shared/schemas'; -import Link from 'next/link'; -import type { SupportedLanguages } from '@/i18n/routing'; +import { Link } from '@/i18n/navigation'; import { useQueryClient } from '@tanstack/react-query'; import posthog from 'posthog-js'; import * as Sentry from '@sentry/nextjs'; @@ -50,7 +48,6 @@ interface ShortUrlDetailContentProps { export function ShortUrlDetailContent({ shortUrl }: ShortUrlDetailContentProps) { const t = useTranslations(); - const locale = useLocale() as SupportedLanguages; const router = useRouter(); const queryClient = useQueryClient(); const [isDeleting, setIsDeleting] = React.useState(false); @@ -69,7 +66,7 @@ export function ShortUrlDetailContent({ shortUrl }: ShortUrlDetailContentProps) posthog.capture('short-url-deleted', { shortCode: shortUrl.shortCode, }); - router.push(`/${locale}/dashboard/short-urls`); + router.push('/dashboard/short-urls'); }, onError: (e) => { const error = e as ApiError; @@ -92,7 +89,7 @@ export function ShortUrlDetailContent({ shortUrl }: ShortUrlDetailContentProps) }); }, }); - }, [shortUrl.shortCode, deleteMutation, router, locale, t]); + }, [shortUrl.shortCode, deleteMutation, router, t]); const handleToggle = () => { toggleMutation.mutate(shortUrl.shortCode, { @@ -126,7 +123,7 @@ export function ShortUrlDetailContent({ shortUrl }: ShortUrlDetailContentProps) - {t('shortUrl.title')} + {t('shortUrl.title')} @@ -149,7 +146,12 @@ export function ShortUrlDetailContent({ shortUrl }: ShortUrlDetailContentProps)
      -