Add business gallery API and marketplace profile fields.
Expose list/add/remove gallery endpoints via media_attachments, plus profile title, banner, and category tag/nameFa support needed by the marketplace dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
d68ef2d08a
commit
3aef62c1b8
@@ -70,6 +70,7 @@ export class BusinessProfileService {
|
||||
logoMedia: true,
|
||||
logoDarkMedia: true,
|
||||
faviconMedia: true,
|
||||
bannerMedia: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -102,6 +103,7 @@ export class BusinessProfileService {
|
||||
logoMedia: true,
|
||||
logoDarkMedia: true,
|
||||
faviconMedia: true,
|
||||
bannerMedia: true,
|
||||
},
|
||||
});
|
||||
if (!business) {
|
||||
@@ -144,6 +146,7 @@ export class BusinessProfileService {
|
||||
logoMedia: true,
|
||||
logoDarkMedia: true,
|
||||
faviconMedia: true,
|
||||
bannerMedia: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -167,6 +170,10 @@ export class BusinessProfileService {
|
||||
await this.assertLogoMedia(businessId, BigInt(dto.faviconMediaId));
|
||||
}
|
||||
|
||||
if (dto.bannerMediaId !== undefined && dto.bannerMediaId !== null) {
|
||||
await this.assertBannerMedia(businessId, BigInt(dto.bannerMediaId));
|
||||
}
|
||||
|
||||
const previousFaviconMediaId = business.faviconMediaId;
|
||||
const faviconExplicitlySet = dto.faviconMediaId !== undefined;
|
||||
const logoChanged =
|
||||
@@ -180,6 +187,7 @@ export class BusinessProfileService {
|
||||
|
||||
if (dto.nameEn !== undefined) data.name = dto.nameEn.trim();
|
||||
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.trim();
|
||||
if (dto.title !== undefined) data.description = dto.title.trim() || null;
|
||||
if (dto.about !== undefined) data.about = dto.about.trim() || null;
|
||||
if (dto.vision !== undefined) data.vision = dto.vision.trim() || null;
|
||||
if (dto.emails !== undefined) {
|
||||
@@ -188,7 +196,9 @@ export class BusinessProfileService {
|
||||
);
|
||||
}
|
||||
if (dto.phoneNumbers !== undefined) {
|
||||
data.phoneNumbers = toPrismaJsonPhoneNumbers(dto.phoneNumbers);
|
||||
data.phoneNumbers = toPrismaJsonPhoneNumbers(
|
||||
normalizePhoneNumbers(dto.phoneNumbers),
|
||||
);
|
||||
}
|
||||
if (dto.socialMedia !== undefined) {
|
||||
data.socialMedia = toPrismaJsonSocialMedia({
|
||||
@@ -222,6 +232,13 @@ export class BusinessProfileService {
|
||||
: { connect: { id: BigInt(dto.faviconMediaId) } };
|
||||
}
|
||||
|
||||
if (dto.bannerMediaId !== undefined) {
|
||||
data.bannerMedia =
|
||||
dto.bannerMediaId === null
|
||||
? { disconnect: true }
|
||||
: { connect: { id: BigInt(dto.bannerMediaId) } };
|
||||
}
|
||||
|
||||
if (Object.keys(data).length > 0) {
|
||||
await tx.business.update({
|
||||
where: { id: businessId },
|
||||
@@ -395,6 +412,7 @@ export class BusinessProfileService {
|
||||
private serializeProfile(business: {
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
description: string | null;
|
||||
about: string | null;
|
||||
vision: string | null;
|
||||
emails: unknown;
|
||||
@@ -406,11 +424,14 @@ export class BusinessProfileService {
|
||||
logoDarkMedia: { publicUrl: string } | null;
|
||||
faviconMediaId: bigint | null;
|
||||
faviconMedia: { publicUrl: string } | null;
|
||||
bannerMediaId: bigint | null;
|
||||
bannerMedia: { publicUrl: string } | null;
|
||||
categoryAssignments: { categoryId: bigint }[];
|
||||
}): BusinessProfile {
|
||||
return {
|
||||
nameEn: business.name,
|
||||
nameFa: business.nameFa ?? '',
|
||||
title: business.description ?? '',
|
||||
about: business.about ?? '',
|
||||
vision: business.vision ?? '',
|
||||
emails: normalizeEmails(business.emails),
|
||||
@@ -425,6 +446,8 @@ export class BusinessProfileService {
|
||||
business.faviconMedia?.publicUrl ??
|
||||
business.logoMedia?.publicUrl ??
|
||||
null,
|
||||
bannerMediaId: business.bannerMediaId?.toString() ?? null,
|
||||
bannerUrl: business.bannerMedia?.publicUrl ?? null,
|
||||
categoryIds: business.categoryAssignments.map((item) =>
|
||||
item.categoryId.toString(),
|
||||
),
|
||||
@@ -433,6 +456,7 @@ export class BusinessProfileService {
|
||||
|
||||
private serializeAddress(address: {
|
||||
id: bigint;
|
||||
label: string | null;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
@@ -441,6 +465,7 @@ export class BusinessProfileService {
|
||||
}): BusinessAddress {
|
||||
return {
|
||||
id: address.id.toString(),
|
||||
name: address.label ?? '',
|
||||
province: address.province,
|
||||
city: address.city,
|
||||
address: address.address,
|
||||
@@ -465,6 +490,7 @@ export class BusinessProfileService {
|
||||
|
||||
for (const item of addresses) {
|
||||
const payload = {
|
||||
label: item.name?.trim() || null,
|
||||
province: item.province.trim(),
|
||||
city: item.city.trim(),
|
||||
address: item.address.trim(),
|
||||
@@ -523,6 +549,28 @@ export class BusinessProfileService {
|
||||
}
|
||||
}
|
||||
|
||||
/** Recommended banner aspect 18:4.5 (4:1). Soft-check when dimensions exist. */
|
||||
private async assertBannerMedia(businessId: bigint, mediaId: bigint) {
|
||||
const media = await this.prisma.media.findFirst({
|
||||
where: { id: mediaId, businessId },
|
||||
select: { id: true, width: true, height: true },
|
||||
});
|
||||
|
||||
if (!media) {
|
||||
throw new BadRequestException('Banner media not found for this business');
|
||||
}
|
||||
|
||||
if (media.width && media.height && media.height > 0) {
|
||||
const ratio = media.width / media.height;
|
||||
const target = 18 / 4.5; // 4
|
||||
if (Math.abs(ratio - target) / target > 0.12) {
|
||||
throw new BadRequestException(
|
||||
'Banner aspect ratio should be about 18:4.5 (4:1)',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async assertPermission(
|
||||
businessId: bigint,
|
||||
userId: bigint,
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export type BusinessPhoneType = 'landline' | 'cell';
|
||||
|
||||
export type BusinessPhoneNumber = {
|
||||
/** Display label — presets like Cell/Landline or any custom text */
|
||||
label: string;
|
||||
type: BusinessPhoneType;
|
||||
number: string;
|
||||
};
|
||||
@@ -26,6 +28,8 @@ export const DEFAULT_BUSINESS_SOCIAL_MEDIA: BusinessSocialMedia = {
|
||||
export type BusinessProfile = {
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
/** Single-line business title / tagline (stored as businesses.description). */
|
||||
title: string;
|
||||
about: string;
|
||||
vision: string;
|
||||
emails: string[];
|
||||
@@ -37,11 +41,15 @@ export type BusinessProfile = {
|
||||
logoDarkUrl: string | null;
|
||||
faviconMediaId: string | null;
|
||||
faviconUrl: string | null;
|
||||
/** Recommended aspect ratio 18:4.5 (4:1). */
|
||||
bannerMediaId: string | null;
|
||||
bannerUrl: string | null;
|
||||
categoryIds: string[];
|
||||
};
|
||||
|
||||
export type BusinessAddress = {
|
||||
id: string;
|
||||
name: string;
|
||||
province: string;
|
||||
city: string;
|
||||
address: string;
|
||||
|
||||
@@ -25,10 +25,17 @@ export function normalizePhoneNumbers(raw: unknown): BusinessPhoneNumber[] {
|
||||
|
||||
return raw
|
||||
.filter(isRecord)
|
||||
.map((item) => ({
|
||||
type: (item.type === 'landline' ? 'landline' : 'cell') as BusinessPhoneNumber['type'],
|
||||
number: readString(item.number).trim(),
|
||||
}))
|
||||
.map((item) => {
|
||||
const type = (
|
||||
item.type === 'landline' ? 'landline' : 'cell'
|
||||
) as BusinessPhoneNumber['type'];
|
||||
const number = readString(item.number).trim();
|
||||
const labelRaw = readString(item.label).trim();
|
||||
const label =
|
||||
labelRaw || (type === 'landline' ? 'Landline' : 'Cell');
|
||||
|
||||
return { label, type, number };
|
||||
})
|
||||
.filter((item) => item.number.length > 0);
|
||||
}
|
||||
|
||||
@@ -50,7 +57,15 @@ export function toPrismaJsonEmails(emails: string[]) {
|
||||
}
|
||||
|
||||
export function toPrismaJsonPhoneNumbers(phoneNumbers: BusinessPhoneNumber[]) {
|
||||
return phoneNumbers;
|
||||
return phoneNumbers.map((item) => {
|
||||
const label =
|
||||
item.label?.trim() || (item.type === 'landline' ? 'Landline' : 'Cell');
|
||||
return {
|
||||
label,
|
||||
type: item.type === 'landline' ? 'landline' : 'cell',
|
||||
number: item.number.trim(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function toPrismaJsonSocialMedia(socialMedia: BusinessSocialMedia) {
|
||||
|
||||
@@ -5,11 +5,17 @@ import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
class BusinessPhoneNumberDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
label?: string;
|
||||
|
||||
@IsIn(['landline', 'cell'])
|
||||
type!: 'landline' | 'cell';
|
||||
|
||||
@@ -49,6 +55,10 @@ class BusinessAddressDto {
|
||||
@IsString()
|
||||
id?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string | null;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
province!: string;
|
||||
@@ -81,6 +91,12 @@ export class UpdateBusinessProfileDto {
|
||||
@MinLength(2)
|
||||
nameFa?: string;
|
||||
|
||||
/** Single-line business title / tagline. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
about?: string;
|
||||
@@ -120,6 +136,12 @@ export class UpdateBusinessProfileDto {
|
||||
@IsInt()
|
||||
faviconMediaId?: number | null;
|
||||
|
||||
/** Marketplace/storefront banner; recommended aspect 18:4.5 (4:1). */
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
bannerMediaId?: number | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@Type(() => Number)
|
||||
|
||||
Reference in New Issue
Block a user