Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
159 changes: 159 additions & 0 deletions app/[locale]/seller/products/product-form-image-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { ProductImagePurpose } from '@/modules/products/domain/value-objects/product-image-purpose';
import type { ProductFormLabels } from '@/modules/products/presentation/product-form-labels';
import type { ProductPhotoDraft } from './product-photo-gallery';
import type {
ProductFormImageSeed,
ProductFormImageSeeds,
ProductPhotoBucket,
ProductPhotoBucketsState,
} from './product-form-types';

function buildDefaultPhotoName(labels: ProductFormLabels, index: number) {
return `${labels.gallery.defaultPhotoName} ${index + 1}`;
}

export function normalizePhotoName(value: string, fallback: string) {
return value.trim() || fallback;
}

export function purposeForBucket(
bucket: ProductPhotoBucket,
): ProductImagePurpose {
if (bucket === 'cover') return ProductImagePurpose.COVER;
if (bucket === 'customizableBase')
return ProductImagePurpose.CUSTOMIZABLE_BASE;
return ProductImagePurpose.SHOWCASE;
}

function bucketForPurpose(
purpose: ProductFormImageSeed['purpose'],
): ProductPhotoBucket {
if (purpose === ProductImagePurpose.COVER) return 'cover';
if (purpose === ProductImagePurpose.CUSTOMIZABLE_BASE)
return 'customizableBase';
return 'showcase';
}

function createPhotoDraft(
seed: ProductFormImageSeed,
fallbackName: string,
purpose: ProductImagePurpose,
): ProductPhotoDraft {
return {
id: seed.id ?? createPhotoId(),
url: seed.url,
alt: normalizePhotoName(seed.alt ?? '', fallbackName),
size: null,
purpose: seed.purpose ?? purpose,
mimeType: seed.mimeType ?? 'image/jpeg',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
posterUrl: seed.posterUrl ?? null,
};
}

function createEmptyBuckets(): ProductPhotoBucketsState {
return { cover: null, showcase: [], customizableBase: [] };
}

export function normalizeInitialImages(
labels: ProductFormLabels,
images: ProductFormImageSeeds,
): ProductPhotoBucketsState {
const buckets = createEmptyBuckets();
const entries = Array.isArray(images)
? images.map((seed) => ({ seed, bucket: bucketForPurpose(seed.purpose) }))
: [
...(images.cover
? [{ seed: images.cover, bucket: 'cover' as const }]
: []),
...images.showcase.map((seed) => ({
seed,
bucket: 'showcase' as const,
})),
...images.customizableBase.map((seed) => ({
seed,
bucket: 'customizableBase' as const,
})),
];
const indexes = { cover: 0, showcase: 0, customizableBase: 0 };

for (const { seed, bucket } of entries) {
const index = indexes[bucket]++;
const draft = createPhotoDraft(
seed,
buildDefaultPhotoName(labels, index),
purposeForBucket(bucket),
);

if (bucket === 'cover') buckets.cover = draft;
else buckets[bucket].push(draft);
}

return buckets;
}

export function moveItem<T extends { id: string }>(
items: T[],
itemId: string,
direction: -1 | 1,
) {
const currentIndex = items.findIndex((item) => item.id === itemId);
const nextIndex = currentIndex + direction;
if (currentIndex === -1 || nextIndex < 0 || nextIndex >= items.length) {
return items;
}

const next = [...items];
const [item] = next.splice(currentIndex, 1);
next.splice(nextIndex, 0, item);
return next;
}

export function updateBucketPhoto(
state: ProductPhotoBucketsState,
bucket: ProductPhotoBucket,
photoId: string,
patch: Partial<ProductPhotoDraft>,
): ProductPhotoBucketsState {
if (bucket === 'cover') {
if (!state.cover || state.cover.id !== photoId) return state;
return { ...state, cover: { ...state.cover, ...patch } };
}

return {
...state,
[bucket]: state[bucket].map((photo) =>
photo.id === photoId ? { ...photo, ...patch } : photo,
),
};
}

export function removeBucketPhoto(
state: ProductPhotoBucketsState,
bucket: ProductPhotoBucket,
photoId: string,
): ProductPhotoBucketsState {
if (bucket === 'cover') {
if (!state.cover || state.cover.id !== photoId) return state;
return { ...state, cover: null };
}

return {
...state,
[bucket]: state[bucket].filter((photo) => photo.id !== photoId),
};
}

export function photoIdsFor(images: ProductPhotoBucketsState) {
return new Set(images.customizableBase.map((photo) => photo.id));
}

export function defaultPhotoName(labels: ProductFormLabels, index: number) {
return buildDefaultPhotoName(labels, index);
}

function createPhotoId() {
return (
globalThis.crypto?.randomUUID?.() ??
`photo-${Date.now()}-${crypto.randomUUID()}`
);
}
97 changes: 97 additions & 0 deletions app/[locale]/seller/products/product-form-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import type { ZodError } from 'zod';
import { productFormSchema } from '@/modules/products/presentation/schemas/product-form-schema';
import { photoIdsFor } from './product-form-image-helpers';
import { cleanPhotoLabels } from './product-form-translation-helpers';
import type {
FormErrors,
FormState,
SupportedLocale,
} from './product-form-types';

export function buildPayload(locale: SupportedLocale, form: FormState) {
const current = form.translations[locale];
const hasCustomizableBase = form.images.customizableBase.length > 0;
const effectiveMode =
hasCustomizableBase && form.customizationConfig?.mode === 'description'
? 'text_photo'
: form.customizationConfig?.mode;
const customizationConfig = form.customizationConfig
? { ...form.customizationConfig, mode: effectiveMode }
: undefined;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const photoIds = photoIdsFor(form.images);
const translations = Object.values(form.translations)
.map((translation) => {
const name = translation.name.trim();
const description = translation.description.trim();
const designChangeDescription =
translation.designChangeDescription?.trim() ?? '';
return {
locale: translation.locale,
name,
description: description || undefined,
tags: [...translation.tags],
sizes: [...translation.sizes],
designChangeDescription: designChangeDescription || null,
photoLabels: cleanPhotoLabels(translation.photoLabels, photoIds),
};
})
.filter(
(translation) =>
translation.name.length > 0 ||
translation.description !== undefined ||
translation.tags.length > 0 ||
translation.sizes.length > 0 ||
translation.designChangeDescription !== null ||
Object.keys(translation.photoLabels).length > 0,
);
const images = [
...(form.images.cover ? [{ ...form.images.cover, position: 0 }] : []),
...form.images.showcase.map((image, index) => ({
...image,
position: index,
})),
...form.images.customizableBase.map((image, index) => ({
...image,
position: index,
})),
].map((image) => ({
id: image.id,
url: image.url,
alt: image.alt.trim(),
position: image.position,
purpose: image.purpose,
mimeType: image.mimeType,
posterUrl: image.posterUrl,
}));
const designChangeDescription = current.designChangeDescription?.trim() ?? '';
const payload = {
locale,
name: current.name.trim(),
description: current.description.trim() || undefined,
price: form.price,
translation: {
tags: [...current.tags],
sizes: [...current.sizes],
designChangeDescription: designChangeDescription || null,
photoLabels: cleanPhotoLabels(current.photoLabels, photoIds),
},
translations,
customizationConfig,
images,
};
const result = productFormSchema.safeParse(payload);
return result.success
? { success: true as const, payload: result.data }
: { success: false as const, error: result.error, payload };
}

export function mapErrors(error: ZodError): FormErrors {
const errors: FormErrors = {};
for (const issue of error.issues) {
const path = issue.path[0] as keyof FormErrors | undefined;
if (path !== undefined && !Object.hasOwn(errors, path)) {
errors[path] = issue.message;
}
}
return errors;
Comment on lines +87 to +107

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

Garantiza un destino visible para todos los errores de Zod.

mapErrors fuerza cualquier primer segmento a FormErrors; rutas como translation o translations quedan en propiedades que la vista ignora. Además, customizationConfig sí está tipado, pero tampoco se renderiza.

  • app/[locale]/seller/products/product-form-payload.ts#L88-L96: mapea rutas desconocidas a un error general en lugar de crear claves invisibles.
  • app/[locale]/seller/products/product-form-types.ts#L77-L83: añade un campo de error general o modela explícitamente las rutas anidadas.
  • app/[locale]/seller/products/product-form-view.tsx#L79-L98: muestra el error general y errors.customizationConfig.
📍 Affects 3 files
  • app/[locale]/seller/products/product-form-payload.ts#L88-L96 (this comment)
  • app/[locale]/seller/products/product-form-types.ts#L77-L83
  • app/[locale]/seller/products/product-form-view.tsx#L79-L98
🤖 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-payload.ts around lines 88 - 96,
Garantiza que todos los errores de Zod tengan un destino visible: actualiza
mapErrors en app/[locale]/seller/products/product-form-payload.ts (88-96) para
conservar solo claves renderizadas y dirigir rutas desconocidas como
translation/translations a un error general; añade ese campo al tipo FormErrors
en app/[locale]/seller/products/product-form-types.ts (77-83); y muestra el
error general y errors.customizationConfig en
app/[locale]/seller/products/product-form-view.tsx (79-98).

}
102 changes: 102 additions & 0 deletions app/[locale]/seller/products/product-form-photo-galleries.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { ProductPhotoBucketGallery } from './product-photo-gallery';
import type { ProductFormLabels } from '@/modules/products/presentation/product-form-labels';
import type { ProductFormController } from './use-product-form';
import styles from './product-form.module.css';

interface ProductFormPhotoGalleriesProps {
controller: ProductFormController;
labels: ProductFormLabels['gallery'];
}

export function ProductFormPhotoGalleries({
controller,
labels,
}: ProductFormPhotoGalleriesProps) {
const { form } = controller;

return (
<>
{controller.photoError ? (
<p className={styles.error} role="alert">
{controller.photoError}
</p>
) : null}
<div className={styles['gallery-stack']}>
<ProductPhotoBucketGallery
mode="single"
labels={labels.buckets.cover}
commonLabels={labels}
photos={form.images.cover ? [form.images.cover] : []}
selectedPhotoId={form.selectedPhotoId}
accept="image/png,image/jpeg,image/webp"
onFilesSelected={(files) => controller.handleUpload('cover', files)}
onPhotoLabelChange={(photoId, alt) =>
controller.updatePhoto('cover', photoId, { alt })
}
onSelectPhoto={controller.selectPhoto}
onRemovePhoto={(photoId) => controller.removePhoto('cover', photoId)}
uploading={controller.uploading}
error={null}
/>
<ProductPhotoBucketGallery
mode="multiple"
labels={labels.buckets.showcase}
commonLabels={labels}
photos={form.images.showcase}
selectedPhotoId={form.selectedPhotoId}
accept="image/png,image/jpeg,image/webp,video/mp4,video/webm"
onFilesSelected={(files) =>
controller.handleUpload('showcase', files)
}
onPhotoLabelChange={(photoId, alt) =>
controller.updatePhoto('showcase', photoId, { alt })
}
onSelectPhoto={controller.selectPhoto}
onRemovePhoto={(photoId) =>
controller.removePhoto('showcase', photoId)
}
onMovePhotoUp={(photoId) =>
controller.movePhoto('showcase', photoId, -1)
}
onMovePhotoDown={(photoId) =>
controller.movePhoto('showcase', photoId, 1)
}
onPosterUrlChange={(photoId, posterUrl) =>
controller.updatePhoto('showcase', photoId, {
posterUrl: posterUrl.trim() || null,
})
}
uploading={controller.uploading}
error={null}
/>
<ProductPhotoBucketGallery
mode="multiple"
labels={labels.buckets.customizableBase}
commonLabels={labels}
photos={form.images.customizableBase}
localizedPhotoLabels={
form.translations[form.activeLocale].photoLabels
}
selectedPhotoId={form.selectedPhotoId}
accept="image/png,image/jpeg,image/webp"
onFilesSelected={(files) =>
controller.handleUpload('customizableBase', files)
}
onPhotoLabelChange={controller.updatePhotoLabel}
onSelectPhoto={controller.selectPhoto}
onRemovePhoto={(photoId) =>
controller.removePhoto('customizableBase', photoId)
}
onMovePhotoUp={(photoId) =>
controller.movePhoto('customizableBase', photoId, -1)
}
onMovePhotoDown={(photoId) =>
controller.movePhoto('customizableBase', photoId, 1)
}
uploading={controller.uploading}
error={null}
/>
</div>
</>
);
}
Loading
Loading