@ecomiq/storefront 2.0.12 → 2.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/bin/ecomiq.mjs +3 -1
  2. package/package.json +4 -2
  3. package/src/auth/create-storefront-auth.ts +65 -6
  4. package/src/auth/ecomiq-oauth-password-constants.ts +1 -3
  5. package/src/auth/ecomiq-oauth-password-plugin.ts +12 -17
  6. package/src/auth/ecomiq-storefront-sign-in.ts +0 -3
  7. package/src/auth/secure-cookies.ts +23 -0
  8. package/src/auth/storefront-auth-base-url.ts +117 -0
  9. package/src/codegen.mjs +27 -1
  10. package/src/components/account/customer-addresses-panel.tsx +678 -0
  11. package/src/components/account/customer-order-detail-panel.tsx +285 -0
  12. package/src/components/account/customer-orders-panel.tsx +192 -0
  13. package/src/components/account/customer-profile-panel.tsx +379 -0
  14. package/src/components/checkout/checkout-contact-section.tsx +17 -11
  15. package/src/components/checkout/checkout-session-view.tsx +83 -2
  16. package/src/components/checkout/checkout-shipping-address-section.tsx +107 -2
  17. package/src/components/checkout/checkout-steps.ts +15 -0
  18. package/src/components/checkout/map-customer-to-checkout.ts +188 -0
  19. package/src/components/checkout/use-checkout-customer-prefill.ts +409 -0
  20. package/src/components/checkout/use-checkout-session.ts +5 -4
  21. package/src/config.d.ts +4 -0
  22. package/src/config.mjs +14 -0
  23. package/src/index.ts +54 -0
  24. package/src/pages/account/account-page-skeleton.tsx +69 -0
  25. package/src/pages/account/account-page-view.tsx +270 -0
  26. package/src/pages/account/account-shell.tsx +101 -0
  27. package/src/pages/account/parse-account-route.ts +36 -0
  28. package/src/pages/account.tsx +16 -0
  29. package/src/pages/checkout.$checkoutId.tsx +1 -1
  30. package/src/providers/auth.tsx +11 -2
  31. package/src/proxy-api.ts +20 -0
  32. package/src/vite-inline.mjs +54 -1
@@ -0,0 +1,409 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type Dispatch,
7
+ type SetStateAction,
8
+ } from 'react'
9
+ import {
10
+ createCustomerAddress,
11
+ customerMeToProfile,
12
+ getCustomerAddresses,
13
+ getCustomerMe,
14
+ toCustomerSavedAddresses,
15
+ type CustomerProfile,
16
+ type CustomerSavedAddress,
17
+ type Department,
18
+ type District,
19
+ type Province,
20
+ } from '@ecomiq/core'
21
+ import { createStorefrontProxyApi } from '../../proxy-api'
22
+ import {
23
+ buildCheckoutContactStepSchema,
24
+ buildCheckoutDeliveryStepSchema,
25
+ } from './form-schema'
26
+ import {
27
+ applySavedAddressToContact,
28
+ checkoutContactToCustomerAddressUpsert,
29
+ findGeoCodeByName,
30
+ mergeCheckoutContactPrefill,
31
+ customerProfileToCheckoutPatch,
32
+ customerAddressToCheckoutPatch,
33
+ pickDefaultCustomerAddress,
34
+ } from './map-customer-to-checkout'
35
+ import {
36
+ jumpCheckoutToPayment,
37
+ type CheckoutStepFlow,
38
+ } from './checkout-steps'
39
+ import type { CheckoutContactForm } from './types'
40
+
41
+ type ShippingFlatItem = { key: string }
42
+
43
+ type UseCheckoutCustomerPrefillArgs = {
44
+ enabled: boolean
45
+ cartId: string
46
+ currency: string
47
+ contact: CheckoutContactForm
48
+ setContact: Dispatch<SetStateAction<CheckoutContactForm>>
49
+ departments: Department[]
50
+ provinces: Province[]
51
+ districts: District[]
52
+ selectedShippingKey: string | null
53
+ shippingFlat: ShippingFlatItem[]
54
+ shippingLoading: boolean
55
+ stepFlow: CheckoutStepFlow
56
+ setStepFlow: Dispatch<SetStateAction<CheckoutStepFlow>>
57
+ persistDeliveryStep: () => Promise<void>
58
+ invalidateDeliveryPersistence: () => void
59
+ /** Si el usuario marcó guardar en cuenta, el auto-avance también debe persistirla. */
60
+ saveAddressIntent?: { enabled: boolean; addressKey: string } | null
61
+ onAddressSaveError?: (message: string) => void
62
+ onAddressSaved?: () => void
63
+ /** Asocia el carrito guest al buyer autenticado (PATCH buyer). */
64
+ associateBuyer?: () => Promise<unknown>
65
+ }
66
+
67
+ export function useCheckoutCustomerPrefill({
68
+ enabled,
69
+ cartId,
70
+ currency,
71
+ contact,
72
+ setContact,
73
+ departments,
74
+ provinces,
75
+ districts,
76
+ selectedShippingKey,
77
+ shippingFlat,
78
+ shippingLoading,
79
+ stepFlow,
80
+ setStepFlow,
81
+ persistDeliveryStep,
82
+ invalidateDeliveryPersistence,
83
+ saveAddressIntent = null,
84
+ onAddressSaveError,
85
+ onAddressSaved,
86
+ associateBuyer,
87
+ }: UseCheckoutCustomerPrefillArgs) {
88
+ const [profile, setProfile] = useState<CustomerProfile | null>(null)
89
+ const [savedAddresses, setSavedAddresses] = useState<CustomerSavedAddress[]>(
90
+ [],
91
+ )
92
+ const [selectedSavedAddressId, setSelectedSavedAddressId] = useState<
93
+ string | null
94
+ >(null)
95
+ const [isLoadingCustomer, setIsLoadingCustomer] = useState(false)
96
+
97
+ const fetchedRef = useRef(false)
98
+ const profileMergedRef = useRef(false)
99
+ const addressMergedRef = useRef(false)
100
+ const buyerAssociatedRef = useRef(false)
101
+ const autoAdvanceRef = useRef(false)
102
+ const autoAdvanceInFlightRef = useRef(false)
103
+
104
+ useEffect(() => {
105
+ fetchedRef.current = false
106
+ profileMergedRef.current = false
107
+ addressMergedRef.current = false
108
+ buyerAssociatedRef.current = false
109
+ autoAdvanceRef.current = false
110
+ autoAdvanceInFlightRef.current = false
111
+ setProfile(null)
112
+ setSavedAddresses([])
113
+ setSelectedSavedAddressId(null)
114
+ }, [cartId])
115
+
116
+ useEffect(() => {
117
+ if (!enabled) return
118
+ if (fetchedRef.current) return
119
+ fetchedRef.current = true
120
+ setIsLoadingCustomer(true)
121
+
122
+ void (async () => {
123
+ try {
124
+ const api = createStorefrontProxyApi()
125
+ const [me, addresses] = await Promise.all([
126
+ getCustomerMe(api),
127
+ getCustomerAddresses(api),
128
+ ])
129
+ const nextProfile = customerMeToProfile(me)
130
+ const saved = toCustomerSavedAddresses(addresses)
131
+ setProfile(nextProfile)
132
+ setSavedAddresses(saved)
133
+
134
+ const defaultAddress = pickDefaultCustomerAddress(saved)
135
+ if (defaultAddress) setSelectedSavedAddressId(defaultAddress.id)
136
+ } catch {
137
+ // Sin perfil/direcciones: el checkout sigue como invitado/manual.
138
+ } finally {
139
+ setIsLoadingCustomer(false)
140
+ }
141
+ })()
142
+ }, [enabled, cartId])
143
+
144
+ // Prefill perfil (campos vacíos).
145
+ useEffect(() => {
146
+ if (!enabled || !profile || profileMergedRef.current) return
147
+ profileMergedRef.current = true
148
+ const patch = customerProfileToCheckoutPatch(profile)
149
+ setContact((current) => mergeCheckoutContactPrefill(current, patch))
150
+ }, [enabled, profile, setContact])
151
+
152
+ // Tras login/registro: asociar el carrito al email del customer.
153
+ useEffect(() => {
154
+ if (!enabled || !associateBuyer) return
155
+ if (buyerAssociatedRef.current || isLoadingCustomer) return
156
+ const email = contact.email.trim()
157
+ const firstName = contact.firstName.trim()
158
+ const lastName = contact.lastName.trim()
159
+ const phone = contact.phone.trim()
160
+ if (!email || !firstName || !lastName || !phone) return
161
+
162
+ buyerAssociatedRef.current = true
163
+ void associateBuyer().catch(() => {
164
+ buyerAssociatedRef.current = false
165
+ })
166
+ }, [
167
+ associateBuyer,
168
+ contact.email,
169
+ contact.firstName,
170
+ contact.lastName,
171
+ contact.phone,
172
+ enabled,
173
+ isLoadingCustomer,
174
+ ])
175
+
176
+ // Prefill dirección default (campos vacíos).
177
+ useEffect(() => {
178
+ if (!enabled || addressMergedRef.current) return
179
+ if (!savedAddresses.length) return
180
+ addressMergedRef.current = true
181
+ const defaultAddress = pickDefaultCustomerAddress(savedAddresses)
182
+ if (!defaultAddress) return
183
+ const patch = customerAddressToCheckoutPatch(defaultAddress, profile)
184
+ setContact((current) => mergeCheckoutContactPrefill(current, patch))
185
+ }, [enabled, profile, savedAddresses, setContact])
186
+
187
+ // Resolver códigos INEI desde nombres.
188
+ useEffect(() => {
189
+ if (!enabled) return
190
+ const isPe = contact.countryCode.toUpperCase() === 'PE'
191
+ if (!isPe) return
192
+
193
+ if (
194
+ contact.departmentName &&
195
+ !contact.departmentCode &&
196
+ departments.length
197
+ ) {
198
+ const code = findGeoCodeByName(departments, contact.departmentName)
199
+ if (code) {
200
+ setContact((c) =>
201
+ c.departmentCode ? c : { ...c, departmentCode: code },
202
+ )
203
+ }
204
+ }
205
+
206
+ if (
207
+ contact.provinceName &&
208
+ contact.departmentCode &&
209
+ !contact.provinceCode &&
210
+ provinces.length
211
+ ) {
212
+ const code = findGeoCodeByName(provinces, contact.provinceName)
213
+ if (code) {
214
+ setContact((c) =>
215
+ c.provinceCode
216
+ ? c
217
+ : { ...c, provinceCode: code, province: c.province || c.provinceName },
218
+ )
219
+ }
220
+ }
221
+
222
+ if (
223
+ contact.districtName &&
224
+ contact.provinceCode &&
225
+ !contact.districtCode &&
226
+ districts.length
227
+ ) {
228
+ const code = findGeoCodeByName(districts, contact.districtName)
229
+ if (code) {
230
+ setContact((c) =>
231
+ c.districtCode
232
+ ? c
233
+ : {
234
+ ...c,
235
+ districtCode: code,
236
+ city: c.city || c.districtName,
237
+ },
238
+ )
239
+ }
240
+ }
241
+ }, [
242
+ contact.countryCode,
243
+ contact.departmentCode,
244
+ contact.departmentName,
245
+ contact.districtCode,
246
+ contact.districtName,
247
+ contact.provinceCode,
248
+ contact.provinceName,
249
+ departments,
250
+ districts,
251
+ enabled,
252
+ provinces,
253
+ setContact,
254
+ ])
255
+
256
+ const saveContactAsCustomerAddress = useCallback(
257
+ async (
258
+ form: CheckoutContactForm,
259
+ options: { addressKey?: string; isDefault?: boolean } = {},
260
+ ) => {
261
+ const payload = checkoutContactToCustomerAddressUpsert(form, {
262
+ addressKey: options.addressKey,
263
+ isDefault:
264
+ options.isDefault ??
265
+ (savedAddresses.length === 0 ||
266
+ !savedAddresses.some((a) => a.isDefault)),
267
+ })
268
+ const api = createStorefrontProxyApi()
269
+ const created = await createCustomerAddress(api, payload)
270
+ const createdId =
271
+ created && typeof created === 'object' && 'id' in created
272
+ ? String(created.id)
273
+ : null
274
+ const refreshed = toCustomerSavedAddresses(await getCustomerAddresses(api))
275
+ setSavedAddresses(refreshed)
276
+ if (createdId) {
277
+ setSelectedSavedAddressId(createdId)
278
+ return refreshed.find((a) => a.id === createdId) ?? null
279
+ }
280
+ const match =
281
+ refreshed.find(
282
+ (a) =>
283
+ a.addressKey === payload.addressKey &&
284
+ a.postalCode === payload.postalCode &&
285
+ a.addressLine === payload.addressLine,
286
+ ) ?? pickDefaultCustomerAddress(refreshed)
287
+ if (match) setSelectedSavedAddressId(match.id)
288
+ return match
289
+ },
290
+ [savedAddresses],
291
+ )
292
+
293
+ // Auto-avanzar a pago cuando contact+delivery están completos.
294
+ useEffect(() => {
295
+ if (!enabled) return
296
+ if (autoAdvanceRef.current || autoAdvanceInFlightRef.current) return
297
+ if (stepFlow.active === 'payment') {
298
+ autoAdvanceRef.current = true
299
+ return
300
+ }
301
+ if (shippingLoading || isLoadingCustomer) return
302
+ if (!selectedShippingKey || shippingFlat.length === 0) return
303
+
304
+ const contactOk = buildCheckoutContactStepSchema().safeParse({
305
+ email: contact.email,
306
+ }).success
307
+ if (!contactOk) return
308
+
309
+ const deliveryOk = buildCheckoutDeliveryStepSchema(currency).safeParse({
310
+ firstName: contact.firstName,
311
+ lastName: contact.lastName,
312
+ countryCode: contact.countryCode,
313
+ address: contact.address,
314
+ phone: contact.phone,
315
+ departmentCode: contact.departmentCode,
316
+ provinceCode: contact.provinceCode,
317
+ districtCode: contact.districtCode,
318
+ city: contact.city,
319
+ province: contact.province,
320
+ selectedShippingKey,
321
+ shippingOptionsCount: shippingFlat.length,
322
+ }).success
323
+ if (!deliveryOk) return
324
+
325
+ autoAdvanceInFlightRef.current = true
326
+ void (async () => {
327
+ try {
328
+ await persistDeliveryStep()
329
+ } catch {
330
+ autoAdvanceInFlightRef.current = false
331
+ return
332
+ }
333
+
334
+ if (saveAddressIntent?.enabled) {
335
+ try {
336
+ await saveContactAsCustomerAddress(contact, {
337
+ addressKey: saveAddressIntent.addressKey,
338
+ })
339
+ onAddressSaved?.()
340
+ } catch (error) {
341
+ autoAdvanceInFlightRef.current = false
342
+ onAddressSaveError?.(
343
+ error instanceof Error
344
+ ? error.message
345
+ : 'No se pudo guardar la dirección en tu cuenta.',
346
+ )
347
+ return
348
+ }
349
+ }
350
+
351
+ autoAdvanceRef.current = true
352
+ setStepFlow(jumpCheckoutToPayment())
353
+ })()
354
+ }, [
355
+ contact,
356
+ currency,
357
+ enabled,
358
+ isLoadingCustomer,
359
+ onAddressSaved,
360
+ onAddressSaveError,
361
+ persistDeliveryStep,
362
+ saveAddressIntent?.addressKey,
363
+ saveAddressIntent?.enabled,
364
+ saveContactAsCustomerAddress,
365
+ selectedShippingKey,
366
+ setStepFlow,
367
+ shippingFlat.length,
368
+ shippingLoading,
369
+ stepFlow.active,
370
+ ])
371
+
372
+ const selectSavedAddress = useCallback(
373
+ (addressId: string) => {
374
+ const address = savedAddresses.find((a) => a.id === addressId)
375
+ if (!address) return
376
+ setSelectedSavedAddressId(addressId)
377
+ autoAdvanceRef.current = false
378
+ autoAdvanceInFlightRef.current = false
379
+ invalidateDeliveryPersistence()
380
+ setContact((current) =>
381
+ applySavedAddressToContact(current, address, profile),
382
+ )
383
+ setStepFlow((flow) => ({
384
+ active: flow.completed.contact || flow.active !== 'contact' ? 'delivery' : flow.active,
385
+ completed: {
386
+ contact: flow.completed.contact || flow.active !== 'contact',
387
+ delivery: false,
388
+ payment: false,
389
+ },
390
+ }))
391
+ },
392
+ [
393
+ invalidateDeliveryPersistence,
394
+ profile,
395
+ savedAddresses,
396
+ setContact,
397
+ setStepFlow,
398
+ ],
399
+ )
400
+
401
+ return {
402
+ profile,
403
+ savedAddresses,
404
+ selectedSavedAddressId,
405
+ isLoadingCustomer,
406
+ selectSavedAddress,
407
+ saveContactAsCustomerAddress,
408
+ }
409
+ }
@@ -354,10 +354,10 @@ export function useCheckoutSession(cartId: string) {
354
354
  const saveBuyer = useCallback(async () => {
355
355
  const buyerCart = await serverApi.patchCartBuyer({
356
356
  cartId,
357
- email: contact.email,
358
- firstName: contact.firstName,
359
- lastName: contact.lastName,
360
- phone: contact.phone,
357
+ email: contact.email.trim(),
358
+ firstName: contact.firstName.trim(),
359
+ lastName: contact.lastName.trim(),
360
+ phone: contact.phone.trim(),
361
361
  })
362
362
  setCheckoutCart(buyerCart)
363
363
  return buyerCart
@@ -611,6 +611,7 @@ export function useCheckoutSession(cartId: string) {
611
611
  clearPaymentSession,
612
612
  persistDeliveryStep,
613
613
  invalidateDeliveryPersistence,
614
+ saveBuyer,
614
615
  providerPaymentCompleted,
615
616
  setProviderPaymentCompleted,
616
617
  payNow,
package/src/config.d.ts CHANGED
@@ -39,6 +39,10 @@ export interface EcomiqConfigInput {
39
39
  apiBaseUrl?: string
40
40
  /** Default `node`. `workerd` emula Cloudflare en local. */
41
41
  runtime?: 'workerd' | 'node'
42
+ /**
43
+ * HTTPS en localhost. `true` = cert auto; `{ cert, key }` = PEM propios.
44
+ */
45
+ https?: boolean | { cert: string; key: string }
42
46
  }
43
47
  deploy?: {
44
48
  target?: 'cloudflare'
package/src/config.mjs CHANGED
@@ -71,6 +71,20 @@ const configSchema = z.object({
71
71
  * `ecomiq build` / `deploy` siempre usan workerd.
72
72
  */
73
73
  runtime: z.enum(['workerd', 'node']),
74
+ /**
75
+ * HTTPS en localhost (`ecomiq dev`).
76
+ * `true` → certificado auto (basic-ssl).
77
+ * `{ cert, key }` → PEM propios relativos al proyecto.
78
+ */
79
+ https: z.union([
80
+ z.boolean(),
81
+ z
82
+ .object({
83
+ cert: z.string().min(1),
84
+ key: z.string().min(1),
85
+ })
86
+ .strict(),
87
+ ]),
74
88
  })
75
89
  .partial()
76
90
  .optional(),
package/src/index.ts CHANGED
@@ -135,6 +135,46 @@ export type {
135
135
  StorefrontReviews,
136
136
  UpdateCartItemQuantityParams,
137
137
  VariantItem,
138
+ CustomerAddress,
139
+ CustomerAddresses,
140
+ CustomerAddressUpsertInput,
141
+ CustomerSavedAddress,
142
+ CustomerMe,
143
+ CustomerProfile,
144
+ CustomerProfilePatch,
145
+ CustomerOrderDetail,
146
+ CustomerOrderDetailDto,
147
+ CustomerOrderListItem,
148
+ CustomerOrderListItemDto,
149
+ } from '@ecomiq/core'
150
+ export {
151
+ CustomerAddressSchema,
152
+ CustomerAddressesSchema,
153
+ CustomerAddressUpsertInputSchema,
154
+ toCustomerSavedAddresses,
155
+ getCustomerAddresses,
156
+ createCustomerAddress,
157
+ updateCustomerAddress,
158
+ deleteCustomerAddress,
159
+ CustomerMeSchema,
160
+ CustomerProfileSchema,
161
+ CustomerProfilePatchSchema,
162
+ CustomerRegisterRequestSchema,
163
+ customerMeToProfile,
164
+ customerMeToProfilePatch,
165
+ customerProfileToPatch,
166
+ getCustomerMe,
167
+ patchCustomer,
168
+ registerCustomer,
169
+ CustomerOrderListItemSchema,
170
+ CustomerOrderDetailSchema,
171
+ CustomerOrderListSchema,
172
+ formatCustomerOrderStatus,
173
+ customerOrderStatusTone,
174
+ formatOrderDate,
175
+ formatOrderDateTime,
176
+ getCustomerOrders,
177
+ getCustomerOrder,
138
178
  } from '@ecomiq/core'
139
179
  export { createClient, getStorefrontClient } from './client'
140
180
  export type {
@@ -148,6 +188,7 @@ export type {
148
188
  } from './review-submission'
149
189
  export { serverApi } from './server'
150
190
  export { proxyStorefrontRequest } from './api-proxy'
191
+ export { createStorefrontProxyApi } from './proxy-api'
151
192
  export {
152
193
  AppEcomiqProvider,
153
194
  useApiQuery,
@@ -176,6 +217,19 @@ export {
176
217
  type TrackableProduct,
177
218
  } from './hooks'
178
219
  export { createStorefrontAuth } from './auth/create-storefront-auth'
220
+ export type { CreateStorefrontAuthOptions } from './auth/create-storefront-auth'
221
+ export {
222
+ BETTER_AUTH_TRUSTED_HOSTS_ENV,
223
+ DEFAULT_STOREFRONT_AUTH_ALLOWED_HOSTS,
224
+ defaultAllowedHostsFromEnv,
225
+ hostsFromStoreSettings,
226
+ mergeSettingsHostsIntoBaseURL,
227
+ resolveStorefrontAuthBaseURL,
228
+ } from './auth/storefront-auth-base-url'
229
+ export type {
230
+ StorefrontDynamicBaseURLConfig,
231
+ StoreSettingsHostsSource,
232
+ } from './auth/storefront-auth-base-url'
179
233
  export { ecomiqOAuthPassword } from './auth/ecomiq-oauth-password-plugin'
180
234
  export { ecomiqOAuthPasswordClient } from './auth/ecomiq-oauth-password-client'
181
235
  export { signInWithEcomiqCredentials } from './auth/ecomiq-storefront-sign-in'
@@ -0,0 +1,69 @@
1
+ import { Skeleton } from '../../ui/skeleton'
2
+ import { cn } from '../../ui/cn'
3
+
4
+ function NavRowSkeleton() {
5
+ return (
6
+ <div className="flex items-center justify-between border-b border-border px-4 py-4">
7
+ <span className="flex items-center gap-3">
8
+ <Skeleton className="size-4 shrink-0 rounded" />
9
+ <Skeleton className="h-4 w-28" />
10
+ </span>
11
+ <Skeleton className="size-4 shrink-0 rounded" />
12
+ </div>
13
+ )
14
+ }
15
+
16
+ export function AccountSectionSkeleton() {
17
+ return (
18
+ <div className="space-y-6 px-6" aria-busy="true" aria-label="Cargando sección">
19
+ <div className="space-y-2">
20
+ <Skeleton className="h-6 w-40" />
21
+ <Skeleton className="h-4 w-full max-w-md" />
22
+ </div>
23
+ <div className="overflow-hidden rounded-xl border border-border bg-background">
24
+ {Array.from({ length: 4 }).map((_, index) => (
25
+ <div
26
+ key={index}
27
+ className={cn(
28
+ 'flex items-center justify-between gap-4 px-5 py-4',
29
+ index < 3 && 'border-b border-border',
30
+ )}
31
+ >
32
+ <div className="min-w-0 flex-1 space-y-2">
33
+ <Skeleton className="h-3 w-24" />
34
+ <Skeleton className="h-4 w-full max-w-xs" />
35
+ </div>
36
+ <Skeleton className="size-8 shrink-0 rounded-md" />
37
+ </div>
38
+ ))}
39
+ </div>
40
+ </div>
41
+ )
42
+ }
43
+
44
+ export function AccountPageSkeleton() {
45
+ return (
46
+ <div
47
+ className="container mx-auto px-4 py-8"
48
+ aria-busy="true"
49
+ aria-label="Cargando cuenta"
50
+ >
51
+ <Skeleton className="h-4 w-24" />
52
+ <Skeleton className="mt-2 h-9 w-40" />
53
+
54
+ <div className="mt-8 grid grid-cols-1 gap-6 lg:grid-cols-[320px_1fr]">
55
+ <aside className="overflow-hidden rounded-xl border border-border bg-background lg:sticky lg:top-15 lg:self-start">
56
+ <nav>
57
+ {Array.from({ length: 5 }).map((_, index) => (
58
+ <NavRowSkeleton key={index} />
59
+ ))}
60
+ </nav>
61
+ </aside>
62
+
63
+ <section className="min-w-0">
64
+ <AccountSectionSkeleton />
65
+ </section>
66
+ </div>
67
+ </div>
68
+ )
69
+ }