Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
3 changes: 2 additions & 1 deletion app/[locale]/admin/sellers/[sellerId]/products/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ export default async function AdminSellerProductsPage({

const sellerRepository = container.getSellerRepository();
const getSeller = new GetSellerUseCase(sellerRepository);
const sellerName = (await getSeller.execute({ sellerId })).name;
const seller = await getSeller.execute({ sellerId });
const sellerName = seller.name;

const productRepository = container.getProductRepository();
const useCase = new ProductListQueryUseCase(productRepository);
Expand Down
41 changes: 33 additions & 8 deletions app/[locale]/admin/sellers/create/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { type ZodError } from 'zod';
import { type ZodError, type ZodIssue } from 'zod';
import { TextField } from '@/shared/ui/text-field';
import { DescriptionField } from '@/shared/ui/description-field';
import { BackLink } from '@/shared/ui/back-link';
Expand Down Expand Up @@ -35,6 +35,37 @@ interface FormErrors {
description?: string;
}

function applyIssue(errors: FormErrors, issue: ZodIssue) {
const path = issue.path?.join('.') || '';
switch (path) {
case 'email': {
errors.email = issue.message;
break;
}
case 'password': {
errors.password = issue.message;
break;
}
case 'firstName': {
errors.firstName = issue.message;
break;
}
case 'lastName': {
errors.lastName = issue.message;
break;
}
case 'name': {
errors.name = issue.message;
break;
}
case 'description': {
errors.description = issue.message;
// No default
break;
}
}
}

function normalizePayload(form: FormState) {
return {
email: form.email.trim(),
Expand Down Expand Up @@ -65,13 +96,7 @@ function validateForm(
const issues = (result.error as ZodError).issues ?? [];

for (const issue of issues) {
const path = issue.path?.join('.') || '';
if (path === 'email') errors.email = issue.message;
else if (path === 'password') errors.password = issue.message;
else if (path === 'firstName') errors.firstName = issue.message;
else if (path === 'lastName') errors.lastName = issue.message;
else if (path === 'name') errors.name = issue.message;
else if (path === 'description') errors.description = issue.message;
applyIssue(errors, issue);
}

return Object.keys(errors).length > 0 ? errors : null;
Expand Down
45 changes: 33 additions & 12 deletions app/[locale]/auth/signup/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState } from 'react';
import { useRouter, useParams } from 'next/navigation';
import { signIn, useSession } from 'next-auth/react';
import { type ZodError } from 'zod';
import { type ZodError, type ZodIssue } from 'zod';
import { Input } from '@/shared/ui/input';
import { Button } from '@/shared/ui/button';
import { ErrorMessage } from '@/shared/ui/error-message';
Expand Down Expand Up @@ -40,6 +40,36 @@ interface FormErrors {
address?: Partial<AddressFields>;
}

function applyIssue(errors: FormErrors, issue: ZodIssue) {
const path = issue.path?.join('.') || '';
switch (path) {
case 'firstName': {
errors.firstName = issue.message;
break;
}
case 'lastName': {
errors.lastName = issue.message;
break;
}
case 'email': {
errors.email = issue.message;
break;
}
case 'password': {
errors.password = issue.message;
break;
}
default: {
if (path.startsWith('address.')) {
const addrField = path.split('.')[1] as keyof AddressFields;
if (!errors.address) errors.address = {};
errors.address[addrField] = issue.message;
}
break;
}
}
}

function validateForm(
form: FormState,
passwordsDoNotMatch: string,
Expand Down Expand Up @@ -68,16 +98,7 @@ function validateForm(
const issues = (result.error as ZodError).issues ?? [];

for (const issue of issues) {
const path = issue.path?.join('.') || '';
if (path === 'firstName') errors.firstName = issue.message;
else if (path === 'lastName') errors.lastName = issue.message;
else if (path === 'email') errors.email = issue.message;
else if (path === 'password') errors.password = issue.message;
else if (path.startsWith('address.')) {
const addrField = path.split('.')[1] as keyof AddressFields;
if (!errors.address) errors.address = {};
errors.address[addrField] = issue.message;
}
applyIssue(errors, issue);
}

return Object.keys(errors).length > 0 ? errors : null;
Expand Down Expand Up @@ -155,7 +176,7 @@ export default function SignUpPage() {
lastName: form.lastName,
email: form.email,
password: form.password,
address: Object.values(form.address).some((v) => v)
address: Object.values(form.address).some(Boolean)
? form.address
: undefined,
}),
Expand Down
2 changes: 1 addition & 1 deletion app/[locale]/checkout/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ export default async function CheckoutPage({
group.items.push(item);
group.subtotal = +(group.subtotal + item.lineTotal).toFixed(2);
}
const sellerGroups = Array.from(sellerMap.values());
const sellerGroups = sellerMap.values().toArray();

// Totals.
const subtotal = +items.reduce((acc, i) => acc + i.lineTotal, 0).toFixed(2);
Expand Down
6 changes: 3 additions & 3 deletions app/[locale]/products/[id]/mockup-canvas-control.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ interface DragState {
}

const MAX_DESIGN_FILE_BYTES = 10 * 1024 * 1024;
const ACCEPTED_MIME_TYPES = ['image/png', 'image/jpeg'];
const ACCEPTED_MIME_TYPES = new Set(['image/png', 'image/jpeg']);

export function MockupCanvasControl({
productImageUrl,
Expand Down Expand Up @@ -163,7 +163,7 @@ export function MockupCanvasControl({
event.target.value = '';
if (!file) return;

if (!ACCEPTED_MIME_TYPES.includes(file.type)) {
if (!ACCEPTED_MIME_TYPES.has(file.type)) {
setError(labels.invalidImage);
return;
}
Expand Down Expand Up @@ -456,7 +456,7 @@ function buildInitialPosition(
return {
...base,
imageUrl: designUrl,
...(initial ?? {}),
...initial,
};
}
return base;
Expand Down
4 changes: 2 additions & 2 deletions app/[locale]/products/[id]/photo-upload-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useState, useTransition } from 'react';

const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
const ACCEPTED_MIME_TYPES = ['image/png', 'image/jpeg'];
const ACCEPTED_MIME_TYPES = new Set(['image/png', 'image/jpeg']);

export interface PhotoUploadResult {
imageUploadId: string;
Expand Down Expand Up @@ -43,7 +43,7 @@ export function PhotoUploadField({
return;
}

if (!ACCEPTED_MIME_TYPES.includes(file.type)) {
if (!ACCEPTED_MIME_TYPES.has(file.type)) {
setError(invalidImageLabel);
return;
}
Expand Down
3 changes: 2 additions & 1 deletion app/[locale]/seller/products/product-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,8 @@ function normalizePhotoName(value: string, fallback: string) {

function createPhotoId() {
return (
globalThis.crypto?.randomUUID?.() ?? `photo-${Date.now()}-${Math.random()}`
globalThis.crypto?.randomUUID?.() ??
`photo-${Date.now()}-${crypto.randomUUID()}`
);
}

Expand Down
4 changes: 1 addition & 3 deletions app/[locale]/seller/products/product-photo-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,7 @@ export function ProductPhotoGallery({
onClick={() => onSelectPhoto(photo.id)}
>
<span className={styles.photoIndex}>{index + 1}</span>
{isSelected
? labels.selectForPreviewLabel
: labels.selectForPreviewLabel}
{labels.selectForPreviewLabel}
</button>

<div className={styles.photoFrame}>
Expand Down
6 changes: 1 addition & 5 deletions app/api/orders/[orderId]/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,5 @@ async function getCurrentSellerId(): Promise<string | null> {
}

function isOrderLifecycleStatus(value: string): value is OrderLifecycleStatus {
return (
value === ORDER_LIFECYCLE_STATUSES.NEW ||
value === ORDER_LIFECYCLE_STATUSES.IN_PROGRESS ||
value === ORDER_LIFECYCLE_STATUSES.COMPLETED
);
return (Object.values(ORDER_LIFECYCLE_STATUSES) as string[]).includes(value);
}
2 changes: 1 addition & 1 deletion app/api/uploads/guest/presigned-url/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createHash } from 'crypto';
import { createHash } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { container } from '@/composition-root/container';
import { handleApiError } from '@/shared/presentation/error-handler';
Expand Down
95 changes: 54 additions & 41 deletions app/api/uploads/local/[...storageKey]/route.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,51 @@
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { NextRequest, NextResponse } from 'next/server';

const STORAGE_ROOT =
process.env.LOCAL_UPLOAD_STORAGE_DIR ?? join(process.cwd(), 'tmp', 'uploads');
async function getStorageRoot(): Promise<string> {
const { join } = await import('node:path');
return (
process.env.LOCAL_UPLOAD_STORAGE_DIR ??
join(process.cwd(), 'tmp', 'uploads')
);
}

async function resolveStoragePath(
storageKey: string[],
root: string,
): Promise<string | null> {
const { resolve, relative, isAbsolute } = await import('node:path');

const decodedKey = decodeURIComponent(storageKey.join('/'));
if (
decodedKey.includes('\\') ||
/^[a-zA-Z]:/.test(decodedKey) ||
decodedKey.startsWith('/')
) {
return null;
}
const segments = decodedKey.split('/').filter(Boolean);

const rawPath = resolve(root, ...segments);
const rawRelative = relative(root, rawPath);
if (rawRelative.startsWith('..') || isAbsolute(rawRelative)) {
return null;
}

const sanitized = segments.map((s) => s.replace(/[:*?"<>|]/g, '_'));
const filePath = resolve(root, ...sanitized);

return filePath;
}

function guessContentType(filePath: string): string {
const lower = filePath.toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.svg')) return 'image/svg+xml';
if (lower.endsWith('.txt')) return 'text/plain; charset=utf-8';
return 'application/octet-stream';
}

export async function PUT(
request: NextRequest,
Expand All @@ -13,8 +55,12 @@ export async function PUT(
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}

const { mkdir, writeFile } = await import('node:fs/promises');
const { dirname } = await import('node:path');

const { storageKey } = await context.params;
const filePath = resolveStoragePath(storageKey);
const root = await getStorageRoot();
const filePath = await resolveStoragePath(storageKey, root);
if (!filePath) {
return NextResponse.json({ error: 'Invalid storage key' }, { status: 400 });
}
Expand All @@ -36,12 +82,14 @@ export async function GET(
}

const { storageKey } = await context.params;
const filePath = resolveStoragePath(storageKey);
const root = await getStorageRoot();
const filePath = await resolveStoragePath(storageKey, root);
if (!filePath) {
return NextResponse.json({ error: 'Invalid storage key' }, { status: 400 });
}

try {
const { readFile } = await import('node:fs/promises');
const body = await readFile(filePath);
return new NextResponse(body, {
status: 200,
Expand All @@ -53,38 +101,3 @@ export async function GET(
return NextResponse.json({ error: 'File not found' }, { status: 404 });
}
}

function resolveStoragePath(storageKey: string[]): string | null {
const root = resolve(STORAGE_ROOT);
const decodedKey = decodeURIComponent(storageKey.join('/'));
if (
decodedKey.includes('\\') ||
/^[a-zA-Z]:/.test(decodedKey) ||
decodedKey.startsWith('/')
) {
return null;
}
const segments = decodedKey.split('/').filter(Boolean);

const rawPath = resolve(root, ...segments);
const rawRelative = relative(root, rawPath);
if (rawRelative.startsWith('..') || isAbsolute(rawRelative)) {
return null;
}

const sanitized = segments.map((s) => s.replace(/[:*?"<>|]/g, '_'));
const filePath = resolve(root, ...sanitized);

return filePath;
}

function guessContentType(filePath: string): string {
const lower = filePath.toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.svg')) return 'image/svg+xml';
if (lower.endsWith('.txt')) return 'text/plain; charset=utf-8';
return 'application/octet-stream';
}
2 changes: 1 addition & 1 deletion components/products/search-input-with-suggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export function SearchInputWithSuggestions({
setActiveIndex((idx) => (idx < suggestions.length - 1 ? idx + 1 : 0));
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActiveIndex((idx) => (idx > 0 ? idx - 1 : suggestions.length - 1));
setActiveIndex((idx) => (idx > 0 ? idx : suggestions.length) - 1);
} else if (e.key === 'Enter' && activeIndex >= 0) {
e.preventDefault();
const choice = suggestions[activeIndex];
Expand Down
Loading
Loading