Skip to content

Feat/ux better details pr3 - #145

Merged
henri318 merged 3 commits into
mainfrom
feat/ux-better-details_pr3
Jul 13, 2026
Merged

Feat/ux better details pr3#145
henri318 merged 3 commits into
mainfrom
feat/ux-better-details_pr3

Conversation

@henri318

@henri318 henri318 commented Jul 13, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • Nuevas funcionalidades

    • Añadidas etiquetas de fotos localizadas para imágenes personalizables.
    • Incorporada protección al abandonar formularios con cambios sin guardar.
    • Las etiquetas de fotos se validan, guardan y sincronizan por idioma.
    • Mejorados los nombres y textos alternativos de imágenes según el idioma.
  • Correcciones

    • Se mantienen correctamente los identificadores de imágenes al editar productos.
    • Se evitan valores indefinidos en las etiquetas de fotos.
  • Pruebas

    • Ampliada la cobertura de etiquetas localizadas, validaciones y protección de navegación.

@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Se incorporan etiquetas localizadas para fotos personalizables, con validación, persistencia y edición por traducción. Las experiencias de personalización y los formularios de vendedor ahora conservan ids y muestran esas etiquetas. También se añade un guard de navegación para cambios no guardados con textos traducidos.

Changes

Etiquetas localizadas y cambios no guardados

Layer / File(s) Summary
Guard de cambios no guardados
shared/hooks/*, shared/ui/button.tsx, shared/i18n/locales/*, modules/sellers/..., app/[locale]/admin/sellers/...
Se añade useUnsavedChangesGuard, textos localizados, acciones del botón y confirmación de navegación en SellerDetailForm.
Contrato y persistencia de etiquetas de fotos
modules/products/application/*, modules/products/domain/*, modules/products/infrastructure/*, modules/products/presentation/*, prisma/*, tests/unit/modules/products/*
photoLabels e ids de imágenes atraviesan los esquemas, casos de uso, mapeadores, repositorio y columna JSON de Prisma, con validaciones y pruebas.
Edición de etiquetas en productos
app/[locale]/seller/products/product-form.tsx, app/[locale]/seller/products/product-photo-gallery.tsx, tests/unit/app/[locale]/seller/products/*
ProductForm sincroniza etiquetas con altas y bajas de fotos, limpia el payload y las muestra por locale en la galería.
Etiquetas en la personalización
app/[locale]/products/[id]/*, tests/unit/app/[locale]/products/[id]/*
CustomizationExperience resuelve etiquetas localizadas, las entrega a CustomizationForm y protege el borrador ante navegación.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Seller as ProductForm
  participant Gallery as ProductPhotoBucketGallery
  participant API as UpdateProductUseCase
  participant DB as PrismaProductRepository
  Seller->>Gallery: muestra localizedPhotoLabels
  Gallery->>Seller: actualiza photoLabel por photo.id
  Seller->>API: envía traducciones e imágenes
  API->>DB: persiste etiquetas limpiadas
Loading

Possibly related PRs

Suggested labels: type:feature

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Title check ❓ Inconclusive El título es demasiado genérico y no describe claramente el cambio principal del pull request. Usa un título específico que resuma el cambio principal, por ejemplo: “Añade protección por cambios sin guardar y etiquetas localizadas en productos”.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ux-better-details_pr3

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@henri318
henri318 merged commit 2329af3 into main Jul 13, 2026
4 of 5 checks passed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
modules/products/application/create-product-use-case.ts (1)

57-59: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Falta photoLabels en el path de traducción única.

El objeto construido en el path de dto.locale + dto.name (líneas 53-60) incluye tags, sizes y designChangeDescription desde dto.translation?.*, pero omite photoLabels. Como resultado, translation.photoLabels siempre es undefined en este path, y el fallback ?? {} en la línea 85 descarta cualquier etiqueta enviada vía dto.translation.photoLabels.

🐛 Propuesta de fix
       {
         locale: dto.locale,
         name: dto.name,
         description: dto.description,
         tags: dto.translation?.tags,
         sizes: dto.translation?.sizes,
         designChangeDescription: dto.translation?.designChangeDescription,
+        photoLabels: dto.translation?.photoLabels,
       },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/products/application/create-product-use-case.ts` around lines 57 -
59, Incluye `photoLabels: dto.translation?.photoLabels` en el objeto de
traducción del path `dto.locale + dto.name`, junto a `tags`, `sizes` y
`designChangeDescription`, para conservar las etiquetas enviadas en
`dto.translation.photoLabels` y evitar que el fallback `?? {}` las descarte.
🧹 Nitpick comments (3)
tests/unit/shared/hooks/use-unsaved-changes-guard.test.tsx (1)

42-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Falta cobertura para el caso de enlaces de solo-hash.

Ya que el hook debería ignorar navegación a #hash en la misma página (ver comentario en shared/hooks/use-unsaved-changes-guard.tsx líneas 38-46), sería valioso añadir un caso de test similar a este que verifique que event.defaultPrevented sigue siendo false para un enlace href="#section" incluso con isDirty = true.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/shared/hooks/use-unsaved-changes-guard.test.tsx` around lines 42 -
52, Añade un caso de prueba junto a “does not intercept a navigation link when
clean” que renderice useUnsavedChangesGuard(true, labels), cree un enlace con
href="`#section`" y dispare un click cancelable. Verifica que
event.defaultPrevented permanezca en false, cubriendo la navegación hash en la
misma página.
shared/hooks/use-unsaved-changes-guard.tsx (1)

47-52: 🎯 Functional Correctness | 🔵 Trivial | 🏗️ Heavy lift

El guard no cubre la navegación con el botón "atrás" del navegador (popstate).

Solo se escuchan beforeunload y click; una navegación vía historial (back/forward) no dispara ninguno de los dos, por lo que el usuario puede perder cambios sin ver el aviso. Implementarlo correctamente requiere interceptar popstate y "cancelarlo" reinsertando una entrada en el historial, lo cual es delicado en Next.js App Router.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/hooks/use-unsaved-changes-guard.tsx` around lines 47 - 52, Amplía el
guard alrededor de handleBeforeUnload y handleClick para interceptar también
popstate durante navegaciones atrás/adelante. Cuando existan cambios sin
guardar, muestra el aviso y reintroduce la entrada necesaria en history para
cancelar la navegación, manteniendo el comportamiento compatible con Next.js App
Router. Registra el listener y elimínalo junto con los listeners existentes en
la limpieza.
tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts (1)

43-82: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cobertura de prueba insuficiente para photoLabels con datos reales.

El fixture makeProduct no incluye photoLabels en las traducciones, por lo que todos los tests solo ejercitan la ruta Prisma.JsonNull. No hay pruebas que verifiquen la persistencia de datos reales (e.g., { 'img-1': 'Frente' }) ni la lógica defensiva del mapper (filtrado de valores no-string, rechazo de arrays).

🧪 Propuesta de test con photoLabels reales
 function makeProduct(overrides: Partial<ProductEntity> = {}): ProductEntity {
   return {
     // ...
     translations: [
       {
         locale: 'es',
         name: 'Camiseta',
         description: 'Camiseta base',
         tags: ['ropa'],
         sizes: ['S', 'M'],
         designChangeDescription: null,
+        photoLabels: { 'img-1': 'Frente', 'img-2': 'Detrás' },
       },
       {
         locale: 'cat',
         name: 'Samarreta',
         description: 'Samarreta base',
         tags: ['roba'],
         sizes: ['M', 'L'],
         designChangeDescription: 'Canvi',
+        photoLabels: { 'img-1': 'Davant' },
       },
     ],
     // ...
   };
 }

Y actualizar las aserciones correspondientes para verificar que photoLabels se persiste con los valores reales en lugar de Prisma.JsonNull.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts`
around lines 43 - 82, Actualiza el fixture makeProduct para incluir photoLabels
reales en las traducciones, por ejemplo { 'img-1': 'Frente' }, y ajusta las
aserciones de persistencia para esperar esos valores en lugar de
Prisma.JsonNull. Añade cobertura del mapper para filtrar valores que no sean
strings y rechazar entradas donde photoLabels sea un array, preservando el
comportamiento defensivo esperado.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/`[locale]/products/[id]/customization-experience.tsx:
- Around line 145-164: Exclude the transient error field from the
unsaved-changes comparison around initialDraftSnapshot and
useUnsavedChangesGuard. Normalize or remove draft.error before comparing it with
the initial snapshot so validation failures do not trigger the guard when no
editable fields changed.

In `@app/`[locale]/seller/products/product-form.tsx:
- Around line 611-627: Alinea initialFormSnapshot con el estado inicial
normalizado: en la construcción de initialFormSnapshot, reutiliza la misma
limpieza de translations mediante cleanPhotoLabels y validPhotoIds aplicada al
estado form dentro del inicializador de useState. Mantén idénticos los valores
normalizados para evitar que photoLabels huérfanos activen el guard al cargar.
- Around line 351-369: Actualiza buildPayload para que una traducción cuyo único
contenido sea photoLabels con valores vacíos no se incluya en translations[].
Considera photoLabels como contenido solo cuando al menos una etiqueta tenga un
valor no vacío, manteniendo la lógica existente para name y otros campos. Aplica
la misma regla en findTranslationWithMissingName para que esas traducciones se
consideren vacías al validar nombres.
- Around line 648-670: Actualiza initialFormSnapshot y la comparación pasada a
useUnsavedChangesGuard para incluir el estado de images, cubriendo cover,
showcase y customizableBase. Usa la misma representación normalizada del orden y
contenido de imágenes en ambos snapshots para detectar adiciones, eliminaciones
y reordenamientos, manteniendo sin cambios la comparación existente de price,
translations y customizationConfig.

In `@modules/sellers/presentation/components/seller-detail-form.tsx`:
- Around line 47-52: Update the unsavedChangesLabels configuration passed to
useUnsavedChangesGuard so each label property—title, message, leave, and
stay—uses its default value when undefined, rather than only defaulting the
entire object. Preserve caller-provided values for fields that are defined.
- Around line 39-46: Actualiza el flujo de SellerDetailForm para que use como
referencia los últimos valores guardados y no el estado saved al activar
useUnsavedChangesGuard. Tras un guardado exitoso en handleSubmit, actualiza esa
referencia con name y description; así el guard permanece desactivado sin
cambios posteriores y vuelve a activarse cuando el usuario edite cualquiera de
esos campos.

In `@shared/hooks/use-unsaved-changes-guard.tsx`:
- Around line 38-46: Actualiza el manejador de navegación para que los enlaces
cuyo destino solo cambia el hash, sin modificar pathname ni search respecto a la
ubicación actual, se ignoren antes de llamar a event.preventDefault() o
setPendingHref. Mantén la protección existente para navegaciones internas que
cambien de página o consulta.

---

Outside diff comments:
In `@modules/products/application/create-product-use-case.ts`:
- Around line 57-59: Incluye `photoLabels: dto.translation?.photoLabels` en el
objeto de traducción del path `dto.locale + dto.name`, junto a `tags`, `sizes` y
`designChangeDescription`, para conservar las etiquetas enviadas en
`dto.translation.photoLabels` y evitar que el fallback `?? {}` las descarte.

---

Nitpick comments:
In `@shared/hooks/use-unsaved-changes-guard.tsx`:
- Around line 47-52: Amplía el guard alrededor de handleBeforeUnload y
handleClick para interceptar también popstate durante navegaciones
atrás/adelante. Cuando existan cambios sin guardar, muestra el aviso y
reintroduce la entrada necesaria en history para cancelar la navegación,
manteniendo el comportamiento compatible con Next.js App Router. Registra el
listener y elimínalo junto con los listeners existentes en la limpieza.

In
`@tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts`:
- Around line 43-82: Actualiza el fixture makeProduct para incluir photoLabels
reales en las traducciones, por ejemplo { 'img-1': 'Frente' }, y ajusta las
aserciones de persistencia para esperar esos valores en lugar de
Prisma.JsonNull. Añade cobertura del mapper para filtrar valores que no sean
strings y rechazar entradas donde photoLabels sea un array, preservando el
comportamiento defensivo esperado.

In `@tests/unit/shared/hooks/use-unsaved-changes-guard.test.tsx`:
- Around line 42-52: Añade un caso de prueba junto a “does not intercept a
navigation link when clean” que renderice useUnsavedChangesGuard(true, labels),
cree un enlace con href="`#section`" y dispare un click cancelable. Verifica que
event.defaultPrevented permanezca en false, cubriendo la navegación hash en la
misma página.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 81c22e95-1602-406e-b943-f80a939941fc

📥 Commits

Reviewing files that changed from the base of the PR and between cbe0e68 and 4467d2c.

📒 Files selected for processing (35)
  • app/[locale]/admin/sellers/[sellerId]/page.tsx
  • app/[locale]/products/[id]/customization-experience.tsx
  • app/[locale]/products/[id]/customization-form.tsx
  • app/[locale]/products/[id]/page.tsx
  • app/[locale]/seller/products/[id]/edit/page.tsx
  • app/[locale]/seller/products/product-form.tsx
  • app/[locale]/seller/products/product-photo-gallery.tsx
  • modules/products/application/create-product-use-case.ts
  • modules/products/application/product-image-builder.ts
  • modules/products/application/update-product-use-case.ts
  • modules/products/domain/entities/product-translation.ts
  • modules/products/infrastructure/mapper.ts
  • modules/products/infrastructure/prisma-product-repository.ts
  • modules/products/presentation/components/product-translation-section.tsx
  • modules/products/presentation/product-form-labels.ts
  • modules/products/presentation/schemas/product-form-schema.ts
  • modules/sellers/presentation/components/seller-detail-form.tsx
  • prisma/migrations/20260713110000_add_product_translation_photo_labels/migration.sql
  • prisma/schema.prisma
  • shared/hooks/use-unsaved-changes-guard.tsx
  • shared/i18n/locales/cat.json
  • shared/i18n/locales/es.json
  • shared/ui/button.tsx
  • tests/unit/app/[locale]/admin/sellers/[sellerId]/page.test.tsx
  • tests/unit/app/[locale]/admin/sellers/[sellerId]/seller-detail-form.test.tsx
  • tests/unit/app/[locale]/products/[id]/customization-experience.test.tsx
  • tests/unit/app/[locale]/products/[id]/customization-form.test.tsx
  • tests/unit/app/[locale]/seller/products/product-photo-gallery.test.tsx
  • tests/unit/modules/products/application/create-product-use-case.test.ts
  • tests/unit/modules/products/application/update-product-use-case.test.ts
  • tests/unit/modules/products/domain/entities/product-translation.test.ts
  • tests/unit/modules/products/infrastructure/prisma-product-repository.test.ts
  • tests/unit/modules/products/presentation/components/product-translation-section.test.tsx
  • tests/unit/modules/products/presentation/schemas/product-form-schema-photo-labels.test.ts
  • tests/unit/shared/hooks/use-unsaved-changes-guard.test.tsx

Comment on lines +145 to +164
const initialDraftSnapshot = {
text: initialDraft?.text ?? null,
color: initialDraft?.color ?? null,
size: initialDraft?.size ?? null,
imageUploadId: initialDraft?.imageUploadId ?? null,
imageUrl: initialDraft?.imageUrl ?? null,
designPosition: initialDraft?.designPosition ?? null,
error: null,
};
const unsavedGuard = useUnsavedChangesGuard(
JSON.stringify(draft) !== JSON.stringify(initialDraftSnapshot),
{
title: labels.unsavedChangesTitle ?? 'Unsaved changes',
message:
labels.unsavedChangesMessage ??
'You have unsaved changes. Leave this page?',
leave: labels.unsavedChangesLeave ?? 'Leave',
stay: labels.unsavedChangesStay ?? 'Stay',
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

El campo transitorio error en la comparación de cambios puede generar falsos positivos.

initialDraftSnapshot incluye error: null y la comparación JSON.stringify(draft) !== JSON.stringify(initialDraftSnapshot) abarca ese campo. Si validateDraft() se ejecuta (al enviar el formulario) y la validación falla, draft.error pasa a un valor no nulo aunque el usuario no haya modificado ningún campo, activando el guard de navegación sin cambios reales.

🔧 Propuesta de fix: normalizar `error` antes de comparar
   const unsavedGuard = useUnsavedChangesGuard(
-    JSON.stringify(draft) !== JSON.stringify(initialDraftSnapshot),
+    JSON.stringify({ ...draft, error: null }) !==
+      JSON.stringify(initialDraftSnapshot),
     {
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const initialDraftSnapshot = {
text: initialDraft?.text ?? null,
color: initialDraft?.color ?? null,
size: initialDraft?.size ?? null,
imageUploadId: initialDraft?.imageUploadId ?? null,
imageUrl: initialDraft?.imageUrl ?? null,
designPosition: initialDraft?.designPosition ?? null,
error: null,
};
const unsavedGuard = useUnsavedChangesGuard(
JSON.stringify(draft) !== JSON.stringify(initialDraftSnapshot),
{
title: labels.unsavedChangesTitle ?? 'Unsaved changes',
message:
labels.unsavedChangesMessage ??
'You have unsaved changes. Leave this page?',
leave: labels.unsavedChangesLeave ?? 'Leave',
stay: labels.unsavedChangesStay ?? 'Stay',
},
);
const initialDraftSnapshot = {
text: initialDraft?.text ?? null,
color: initialDraft?.color ?? null,
size: initialDraft?.size ?? null,
imageUploadId: initialDraft?.imageUploadId ?? null,
imageUrl: initialDraft?.imageUrl ?? null,
designPosition: initialDraft?.designPosition ?? null,
error: null,
};
const unsavedGuard = useUnsavedChangesGuard(
JSON.stringify({ ...draft, error: null }) !==
JSON.stringify(initialDraftSnapshot),
{
title: labels.unsavedChangesTitle ?? 'Unsaved changes',
message:
labels.unsavedChangesMessage ??
'You have unsaved changes. Leave this page?',
leave: labels.unsavedChangesLeave ?? 'Leave',
stay: labels.unsavedChangesStay ?? 'Stay',
},
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`[locale]/products/[id]/customization-experience.tsx around lines 145 -
164, Exclude the transient error field from the unsaved-changes comparison
around initialDraftSnapshot and useUnsavedChangesGuard. Normalize or remove
draft.error before comparing it with the initial snapshot so validation failures
do not trigger the guard when no editable fields changed.

Comment on lines +351 to +369
function addPhotoLabels(
translations: TranslationMap,
photoIds: readonly string[],
): TranslationMap {
if (photoIds.length === 0) return translations;

return Object.fromEntries(
Object.entries(translations).map(([locale, translation]) => [
locale,
{
...translation,
photoLabels: {
...translation.photoLabels,
...Object.fromEntries(photoIds.map((photoId) => [photoId, ''])),
},
},
]),
) as TranslationMap;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm whether findTranslationWithMissingName considers photoLabels, and inspect the full schema used for the `translations` array field.
rg -n "findTranslationWithMissingName" -A 20 "app/[locale]/seller/products/product-form.tsx"
rg -n "productTranslationInputSchema|translations:" -A 5 "modules/products/presentation/schemas/product-form-schema.ts"

Repository: henri318/728-store

Length of output: 2223


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the helpers and payload construction around the mentioned lines.
sed -n '300,520p' "app/[locale]/seller/products/product-form.tsx"

echo '--- mapErrors / schema area ---'
sed -n '1,220p' "modules/products/presentation/schemas/product-form-schema.ts"

Repository: henri318/728-store

Length of output: 11049


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the error mapper to see how translation schema errors are surfaced.
rg -n "function mapErrors|const mapErrors|mapErrors\\(" "app/[locale]/seller/products/product-form.tsx" -A 120 -B 20

Repository: henri318/728-store

Length of output: 6354


No cuentes photoLabels vacíos como contenido en buildPayload
Una traducción con solo photoLabels: { [id]: '' } entra en translations[] aunque name siga vacío; eso hace que productTranslationInputSchema rechace el payload y el error quede mal expuesto porque mapErrors solo mira issue.path[0]. Aplica la misma regla en findTranslationWithMissingName si ahí también debe considerarse vacía.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`[locale]/seller/products/product-form.tsx around lines 351 - 369,
Actualiza buildPayload para que una traducción cuyo único contenido sea
photoLabels con valores vacíos no se incluya en translations[]. Considera
photoLabels como contenido solo cuando al menos una etiqueta tenga un valor no
vacío, manteniendo la lógica existente para name y otros campos. Aplica la misma
regla en findTranslationWithMissingName para que esas traducciones se consideren
vacías al validar nombres.

Comment on lines 611 to +627
const [form, setForm] = useState<FormState>(() => {
const images = normalizeInitialImages(labels, initialValues.images);
const validPhotoIds = photoIdsFor(images);
const translations = Object.fromEntries(
Object.entries(buildTranslationMap(initialLocale, initialValues)).map(
([translationLocale, translation]) => [
translationLocale,
{
...translation,
photoLabels: cleanPhotoLabels(
translation.photoLabels,
validPhotoIds,
),
},
],
),
) as TranslationMap;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Inconsistencia entre el estado inicial del formulario y el snapshot usado por el guard.

Al inicializar form, translations se limpia con cleanPhotoLabels(..., validPhotoIds) (Líneas 620-623), pero initialFormSnapshot usa buildTranslationMap(...) sin limpiar (Línea 652). Si existen photoLabels huérfanos en datos del servidor (referenciando fotos ya no presentes), el snapshot inicial y el estado inicial diferirán, provocando que el guard marque "cambios sin guardar" nada más cargar la página, sin que el usuario haya tocado nada.

Also applies to: 648-656

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`[locale]/seller/products/product-form.tsx around lines 611 - 627, Alinea
initialFormSnapshot con el estado inicial normalizado: en la construcción de
initialFormSnapshot, reutiliza la misma limpieza de translations mediante
cleanPhotoLabels y validPhotoIds aplicada al estado form dentro del
inicializador de useState. Mantén idénticos los valores normalizados para evitar
que photoLabels huérfanos activen el guard al cargar.

Comment on lines +648 to +670
const initialFormSnapshot = useMemo(
() =>
JSON.stringify({
price: String(initialValues.price),
translations: buildTranslationMap(initialLocale, initialValues),
customizationConfig: initialValues.customizationConfig,
}),
[initialLocale, initialValues],
);
const unsavedGuard = useUnsavedChangesGuard(
mode === 'edit' &&
JSON.stringify({
price: form.price,
translations: form.translations,
customizationConfig: form.customizationConfig,
}) !== initialFormSnapshot,
labels.unsavedChanges ?? {
title: 'Unsaved changes',
message: 'You have unsaved changes. Leave this page?',
leave: 'Leave',
stay: 'Stay',
},
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

El guard de cambios no guardados no detecta cambios en images.

initialFormSnapshot y la condición pasada a useUnsavedChangesGuard solo comparan {price, translations, customizationConfig}. Añadir/eliminar/reordenar fotos en cover o showcase, o reordenar customizableBase, no modifica translations, por lo que el usuario puede navegar fuera sin advertencia pese a tener cambios de fotos sin guardar.

💡 Propuesta de fix
   const initialFormSnapshot = useMemo(
     () =>
       JSON.stringify({
         price: String(initialValues.price),
         translations: buildTranslationMap(initialLocale, initialValues),
         customizationConfig: initialValues.customizationConfig,
+        images: normalizeInitialImages(labels, initialValues.images),
       }),
     [initialLocale, initialValues],
   );
   const unsavedGuard = useUnsavedChangesGuard(
     mode === 'edit' &&
       JSON.stringify({
         price: form.price,
         translations: form.translations,
         customizationConfig: form.customizationConfig,
+        images: form.images,
       }) !== initialFormSnapshot,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const initialFormSnapshot = useMemo(
() =>
JSON.stringify({
price: String(initialValues.price),
translations: buildTranslationMap(initialLocale, initialValues),
customizationConfig: initialValues.customizationConfig,
}),
[initialLocale, initialValues],
);
const unsavedGuard = useUnsavedChangesGuard(
mode === 'edit' &&
JSON.stringify({
price: form.price,
translations: form.translations,
customizationConfig: form.customizationConfig,
}) !== initialFormSnapshot,
labels.unsavedChanges ?? {
title: 'Unsaved changes',
message: 'You have unsaved changes. Leave this page?',
leave: 'Leave',
stay: 'Stay',
},
);
const initialFormSnapshot = useMemo(
() =>
JSON.stringify({
price: String(initialValues.price),
translations: buildTranslationMap(initialLocale, initialValues),
customizationConfig: initialValues.customizationConfig,
images: normalizeInitialImages(labels, initialValues.images),
}),
[initialLocale, initialValues],
);
const unsavedGuard = useUnsavedChangesGuard(
mode === 'edit' &&
JSON.stringify({
price: form.price,
translations: form.translations,
customizationConfig: form.customizationConfig,
images: form.images,
}) !== initialFormSnapshot,
labels.unsavedChanges ?? {
title: 'Unsaved changes',
message: 'You have unsaved changes. Leave this page?',
leave: 'Leave',
stay: 'Stay',
},
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/`[locale]/seller/products/product-form.tsx around lines 648 - 670,
Actualiza initialFormSnapshot y la comparación pasada a useUnsavedChangesGuard
para incluir el estado de images, cubriendo cover, showcase y customizableBase.
Usa la misma representación normalizada del orden y contenido de imágenes en
ambos snapshots para detectar adiciones, eliminaciones y reordenamientos,
manteniendo sin cambios la comparación existente de price, translations y
customizationConfig.

Comment on lines 39 to +46
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const guard = useUnsavedChangesGuard(
saved === null &&
(name !== initialName || description !== initialDescription),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

El guard deja de proteger tras el primer guardado exitoso, aunque haya nuevos cambios sin guardar.

La condición saved === null && (name !== initialName || description !== initialDescription) usa initialName/initialDescription (valores del montaje) y saved (que solo vuelve a null al iniciar un nuevo handleSubmit). Tras guardar una vez, saved queda con el mensaje de éxito; si el usuario sigue editando después de ese guardado y trata de navegar sin volver a enviar el formulario, saved !== null hace que la condición completa sea false y el guard no se activa, pese a existir cambios reales sin persistir.

🐛 Propuesta de fix
+  const [lastSaved, setLastSaved] = useState({
+    name: initialName,
+    description: initialDescription,
+  });
   const guard = useUnsavedChangesGuard(
-    saved === null &&
-      (name !== initialName || description !== initialDescription),
+    name !== lastSaved.name || description !== lastSaved.description,
     unsavedChangesLabels ?? {
       title: 'Unsaved changes',
       message: 'You have unsaved changes. Leave this page?',
       leave: 'Leave',
       stay: 'Stay',
     },
   );

Y en handleSubmit, tras setSaved(savedLabel):

       setSaved(savedLabel);
+      setLastSaved({ name: name.trim(), description: description.trim() });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const guard = useUnsavedChangesGuard(
saved === null &&
(name !== initialName || description !== initialDescription),
const [name, setName] = useState(initialName);
const [description, setDescription] = useState(initialDescription);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
const [lastSaved, setLastSaved] = useState({
name: initialName,
description: initialDescription,
});
const guard = useUnsavedChangesGuard(
name !== lastSaved.name || description !== lastSaved.description,
unsavedChangesLabels ?? {
title: 'Unsaved changes',
message: 'You have unsaved changes. Leave this page?',
leave: 'Leave',
stay: 'Stay',
},
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/sellers/presentation/components/seller-detail-form.tsx` around lines
39 - 46, Actualiza el flujo de SellerDetailForm para que use como referencia los
últimos valores guardados y no el estado saved al activar
useUnsavedChangesGuard. Tras un guardado exitoso en handleSubmit, actualiza esa
referencia con name y description; así el guard permanece desactivado sin
cambios posteriores y vuelve a activarse cuando el usuario edite cualquiera de
esos campos.

Comment on lines +47 to +52
unsavedChangesLabels ?? {
title: 'Unsaved changes',
message: 'You have unsaved changes. Leave this page?',
leave: 'Leave',
stay: 'Stay',
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect the target component around the cited lines.
FILE="modules/sellers/presentation/components/seller-detail-form.tsx"
wc -l "$FILE"
sed -n '1,140p' "$FILE"

# 2) Inspect the caller mentioned in the comment.
PAGE="app/[locale]/admin/sellers/[sellerId]/page.tsx"
wc -l "$PAGE"
sed -n '1,140p' "$PAGE"

# 3) Find all references to unsavedChangesLabels to understand its shape and defaults.
rg -n "unsavedChangesLabels" .

# 4) If there is a translation/localization helper nearby, inspect related label composition.
rg -n "Unsaved changes|Leave this page|Leave|Stay" modules app .

Repository: henri318/728-store

Length of output: 11400


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="shared/hooks/use-unsaved-changes-guard.tsx"
wc -l "$FILE"
sed -n '1,220p' "$FILE"

# Inspect the product customization pattern that already handles per-field fallbacks.
FILE2="app/[locale]/products/[id]/customization-experience.tsx"
wc -l "$FILE2"
sed -n '140,175p' "$FILE2"

Repository: henri318/728-store

Length of output: 4081


Aplica ?? por campo en las etiquetas del guard
unsavedChangesLabels puede llegar con campos undefined, y useUnsavedChangesGuard los renderiza tal cual; el modal quedará con textos vacíos. El fallback actual solo cubre el objeto completo, así que conviene resolver cada propiedad con ?? antes de llamar al hook.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@modules/sellers/presentation/components/seller-detail-form.tsx` around lines
47 - 52, Update the unsavedChangesLabels configuration passed to
useUnsavedChangesGuard so each label property—title, message, leave, and
stay—uses its default value when undefined, rather than only defaulting the
entire object. Preserve caller-provided values for fields that are defined.

Comment on lines +38 to +46
const anchor = (event.target as Element | null)?.closest('a');
if (!anchor || anchor.target === '_blank' || !anchor.href) return;
const destination = new URL(anchor.href, globalThis.location.href);
if (destination.origin !== globalThis.location.origin) return;
event.preventDefault();
setPendingHref(
`${destination.pathname}${destination.search}${destination.hash}`,
);
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

El guard bloquea también la navegación por anclas internas (#hash).

destination.pathname/search/hash se comparan solo por origin, así que un enlace href="#section" (mismo pathname, sin recarga) también dispara preventDefault() y el modal de "cambios sin guardar", impidiendo el scroll normal a la sección incluso si el usuario decide "quedarse" (el preventDefault original nunca se revierte).

🩹 Propuesta de fix
       const destination = new URL(anchor.href, globalThis.location.href);
       if (destination.origin !== globalThis.location.origin) return;
+      const isSamePageHashLink =
+        destination.pathname === globalThis.location.pathname &&
+        destination.search === globalThis.location.search &&
+        destination.hash !== '';
+      if (isSamePageHashLink) return;
       event.preventDefault();
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const anchor = (event.target as Element | null)?.closest('a');
if (!anchor || anchor.target === '_blank' || !anchor.href) return;
const destination = new URL(anchor.href, globalThis.location.href);
if (destination.origin !== globalThis.location.origin) return;
event.preventDefault();
setPendingHref(
`${destination.pathname}${destination.search}${destination.hash}`,
);
};
const anchor = (event.target as Element | null)?.closest('a');
if (!anchor || anchor.target === '_blank' || !anchor.href) return;
const destination = new URL(anchor.href, globalThis.location.href);
if (destination.origin !== globalThis.location.origin) return;
const isSamePageHashLink =
destination.pathname === globalThis.location.pathname &&
destination.search === globalThis.location.search &&
destination.hash !== '';
if (isSamePageHashLink) return;
event.preventDefault();
setPendingHref(
`${destination.pathname}${destination.search}${destination.hash}`,
);
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@shared/hooks/use-unsaved-changes-guard.tsx` around lines 38 - 46, Actualiza
el manejador de navegación para que los enlaces cuyo destino solo cambia el
hash, sin modificar pathname ni search respecto a la ubicación actual, se
ignoren antes de llamar a event.preventDefault() o setPendingHref. Mantén la
protección existente para navegaciones internas que cambien de página o
consulta.

This was referenced Jul 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant