@@ -12,17 +12,14 @@ import type {UrlObject} from 'url'
1212
1313/* eslint-disable @typescript-eslint/naming-convention */
1414declare const __adrsbl : { run : ( event : string , conversion : boolean ) => void } | undefined
15- /* eslint-enable @typescript-eslint/naming-convention */
1615
1716type TLocalizedLinkProps = LinkProps &
18- AnchorHTMLAttributes < HTMLAnchorElement > & {
17+ Omit < AnchorHTMLAttributes < HTMLAnchorElement > , 'href' > & {
1918 children : ReactNode
2019 }
2120
2221/**
23- * A localized version of Next.js Link that automatically prepends the current language
24- * to internal links when needed.
25- *
22+ * A localized version of Next.js Link that automatically prepends the current language to internal links when needed.
2623 * - Preserves UrlObject hrefs (pathname, query, hash).
2724 * - Detects and leaves external links unchanged.
2825 * - Opens app.shapeshift.com links in a new tab and fires the Addressable pixel event.
@@ -32,22 +29,92 @@ export const LocalizedLink = forwardRef<HTMLAnchorElement, TLocalizedLinkProps>(
3229 const pathname = usePathname ( )
3330 const currentLanguage = getLanguageFromPath ( pathname ) || DEFAULT_LANGUAGE
3431
35- // Convert href to string for processing
36- const hrefString =
37- typeof href === 'string'
38- ? href
39- : typeof href === 'object' &&
40- href !== null &&
41- 'pathname' in href &&
42- typeof ( href as UrlObject ) . pathname === 'string'
43- ? ( href as UrlObject ) . pathname
44- : ''
32+ // Preserve UrlObject hrefs (pathname + query + hash) when provided
33+ const hrefObj = typeof href === 'object' && href !== null ? ( href as UrlObject ) : undefined
34+
35+ // Convert href to string for pathname-based processing when needed
36+ const hrefString = typeof href === 'string' ? href : ( hrefObj ?. pathname ?? '' )
4537
46- // External app.shapeshift.com link detection
47- const isAppLink = typeof hrefString === 'string' && / ^ h t t p s ? : \/ \/ a p p \. s h a p e s h i f t \. c o m ( \/ | $ ) / i. test ( hrefString )
38+ // Helper to build a full absolute URL string from a UrlObject when it has host/protocol
39+ const fullUrlFromObj = ( obj : UrlObject ) => {
40+ if ( ! obj ) {
41+ return ''
42+ }
43+ const protocol = ( obj . protocol as string ) ?? ''
44+ const host = ( obj . host as string ) ?? ( obj . hostname as string ) ?? ''
45+ const pathname = ( obj . pathname as string ) ?? ''
46+ const hash = ( obj . hash as string ) ?? ''
47+ let query = ''
48+ // Support both string-form and object-form queries.
49+ if ( obj . query ) {
50+ if ( typeof obj . query === 'string' ) {
51+ // Use provided string query, ensure it starts with '?'
52+ const raw = obj . query as string
53+ query = raw . startsWith ( '?' ) ? raw : `?${ raw } `
54+ } else if ( typeof obj . query === 'object' ) {
55+ // obj.query can be Record<string, string | string[]>.
56+ const params = new URLSearchParams ( )
57+ for ( const [ k , v ] of Object . entries ( obj . query as Record < string , string | string [ ] > ) ) {
58+ if ( Array . isArray ( v ) ) {
59+ for ( const item of v ) {
60+ params . append ( k , String ( item ) )
61+ }
62+ } else if ( v !== undefined && v !== null ) {
63+ params . append ( k , String ( v ) )
64+ }
65+ }
66+ const qs = params . toString ( )
67+ query = qs ? `?${ qs } ` : ''
68+ }
69+ }
70+ if ( host ) {
71+ // If protocol is provided, include it; otherwise emit a
72+ // protocol-relative URL ("//host/path") rather than defaulting
73+ // to https:. This avoids making assumptions about the desired
74+ // transport protocol.
75+ const protoPrefix = protocol ? `${ protocol } //` : '//'
76+ return `${ protoPrefix } ${ host } ${ pathname } ${ query } ${ hash } `
77+ }
78+ return `${ pathname } ${ query } ${ hash } `
79+ }
80+
81+ // External app.shapeshift.com link detection.
82+ // Match http(s)://app.shapeshift.com, protocol-relative //app.shapeshift.com,
83+ // and UrlObject cases where host/hostname equals app.shapeshift.com.
84+ const isAppLink = ( ( ) => {
85+ const appHostRegex = / ^ (?: h t t p s ? : ) ? \/ \/ a p p \. s h a p e s h i f t \. c o m ( \/ | $ ) / i
86+ if ( typeof href === 'string' ) {
87+ return appHostRegex . test ( href )
88+ }
89+ if ( hrefObj ) {
90+ // If host/hostname explicitly provided, compare directly (covers host-only UrlObject)
91+ const host = ( hrefObj . host as string ) ?? ( hrefObj . hostname as string ) ?? ''
92+ if ( host && host . toLowerCase ( ) === 'app.shapeshift.com' ) {
93+ return true
94+ }
95+
96+ const full = fullUrlFromObj ( hrefObj )
97+ return appHostRegex . test ( full )
98+ }
99+ return false
100+ } ) ( )
48101
49102 // Compose click handler for external app links
50103 const handleClick = ( e : React . MouseEvent < HTMLAnchorElement > ) => {
104+ // Invoke the user's handler first so they may call e.preventDefault().
105+ if ( onClick ) {
106+ try {
107+ onClick ( e )
108+ } catch {
109+ // Swallow user handler errors to avoid breaking navigation logic.
110+ }
111+ }
112+
113+ // If the user's handler prevented default, respect that and do nothing.
114+ if ( e . defaultPrevented ) {
115+ return
116+ }
117+
51118 if ( isAppLink ) {
52119 try {
53120 if ( __adrsbl ?. run ) {
@@ -56,27 +123,50 @@ export const LocalizedLink = forwardRef<HTMLAnchorElement, TLocalizedLinkProps>(
56123 } catch {
57124 // ignore
58125 }
59- e . preventDefault ( )
126+
127+ const absolute = hrefObj
128+ ? fullUrlFromObj ( hrefObj )
129+ : hrefString || ( typeof href === 'string' ? href : '' )
130+ if ( ! absolute ) {
131+ return
132+ }
133+
60134 try {
61- window . open ( hrefString || ( typeof href === 'string' ? href : '' ) , '_blank' , 'noopener,noreferrer' )
135+ // Prevent default navigation so we can control behavior and fallbacks.
136+ e . preventDefault ( )
137+
138+ // Attempt to open a new tab. Some browsers/pop-up blockers return null.
139+ const newWin = window . open ( absolute , '_blank' , 'noopener,noreferrer' )
140+ if ( ! newWin ) {
141+ // Popup blocked or couldn't open new tab — navigate in the same tab.
142+ try {
143+ window . location . assign ( absolute )
144+ } catch {
145+ // ignore
146+ }
147+ }
62148 } catch {
63- if ( hrefString ) {
64- window . location . assign ( hrefString )
149+ // In case window.open throws for some reason, fallback to assign.
150+ try {
151+ window . location . assign ( absolute )
152+ } catch {
153+ // ignore
65154 }
66155 }
67156 }
68- if ( onClick ) {
69- onClick ( e )
70- }
71157 }
72158
73159 // Don't modify external links, anchors, or already localized paths
160+ const isExternalObject = ! ! hrefObj && ! ! ( hrefObj . host || hrefObj . hostname || hrefObj . protocol )
74161 if (
75- hrefString ?. startsWith ( 'http' ) ||
76- hrefString ?. startsWith ( '#' ) ||
77- hrefString ?. startsWith ( 'mailto:' ) ||
78- hrefString ?. startsWith ( 'tel:' ) ||
79- ! hrefString ?. startsWith ( '/' )
162+ typeof href === 'string'
163+ ? hrefString . startsWith ( 'http' ) ||
164+ hrefString . startsWith ( '#' ) ||
165+ hrefString . startsWith ( 'mailto:' ) ||
166+ hrefString . startsWith ( 'tel:' ) ||
167+ ! hrefString . startsWith ( '/' )
168+ : // href is an object
169+ isExternalObject
80170 ) {
81171 return (
82172 < Link
@@ -92,12 +182,18 @@ export const LocalizedLink = forwardRef<HTMLAnchorElement, TLocalizedLinkProps>(
92182 // Check if the href already has a language prefix
93183 const hasLanguagePrefix = hrefString . match ( / ^ \/ ( [ a - z ] { 2 } ) ( \/ | $ ) / )
94184
95- // Build the localized href
96- let localizedHref = hrefString
185+ // Build the localized href. Preserve query/hash by returning a UrlObject when the original
186+ // href was a UrlObject, otherwise return a string.
187+ let localizedPathname = hrefString
97188 if ( ! hasLanguagePrefix && currentLanguage !== DEFAULT_LANGUAGE ) {
98- localizedHref = `/${ currentLanguage } ${ hrefString } `
189+ localizedPathname = `/${ currentLanguage } ${ hrefString } `
99190 }
100191
192+ const localizedHref = hrefObj
193+ ? // copy the original UrlObject but replace/normalize pathname
194+ ( { ...hrefObj , pathname : localizedPathname } as UrlObject )
195+ : localizedPathname
196+
101197 return (
102198 < Link
103199 href = { localizedHref }
0 commit comments