Skip to content

Commit fbd41eb

Browse files
committed
chore: meeting coderabbit suggestions
1 parent 5fd45ef commit fbd41eb

File tree

1 file changed

+128
-32
lines changed

1 file changed

+128
-32
lines changed

app/[lang]/_components/LocalizedLink.tsx

Lines changed: 128 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,14 @@ import type {UrlObject} from 'url'
1212

1313
/* eslint-disable @typescript-eslint/naming-convention */
1414
declare const __adrsbl: {run: (event: string, conversion: boolean) => void} | undefined
15-
/* eslint-enable @typescript-eslint/naming-convention */
1615

1716
type 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' && /^https?:\/\/app\.shapeshift\.com(\/|$)/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 = /^(?:https?:)?\/\/app\.shapeshift\.com(\/|$)/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

Comments
 (0)