Add dark-mode business logo, payment order steps, and Arvan DNS diagnostics.
Store logo_dark_media_id on businesses, expose logoDarkUrl on tenant/website APIs, prepend awaiting-payment steps to order workflows, and surface clearer Arvan API network errors. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
e942348b1d
commit
6ce6f959ac
@@ -0,0 +1,10 @@
|
|||||||
|
-- Optional dark-theme logo for business profile / storefront dashboards
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_dark_media_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
DROP CONSTRAINT IF EXISTS businesses_logo_dark_media_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD CONSTRAINT businesses_logo_dark_media_id_fkey
|
||||||
|
FOREIGN KEY (logo_dark_media_id) REFERENCES media (id) ON DELETE SET NULL;
|
||||||
@@ -169,6 +169,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"logoDarkUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional logo for dark backgrounds; falls back to logoUrl when omitted."
|
||||||
|
},
|
||||||
"faviconUrl": {
|
"faviconUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ model Business {
|
|||||||
phoneNumbers Json @default("[]") @map("phone_numbers")
|
phoneNumbers Json @default("[]") @map("phone_numbers")
|
||||||
socialMedia Json @default("{}") @map("social_media")
|
socialMedia Json @default("{}") @map("social_media")
|
||||||
logoMediaId BigInt? @map("logo_media_id")
|
logoMediaId BigInt? @map("logo_media_id")
|
||||||
|
logoDarkMediaId BigInt? @map("logo_dark_media_id")
|
||||||
faviconMediaId BigInt? @map("favicon_media_id")
|
faviconMediaId BigInt? @map("favicon_media_id")
|
||||||
oldBusinessId BigInt? @map("old_business_id")
|
oldBusinessId BigInt? @map("old_business_id")
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
@@ -79,6 +80,7 @@ model Business {
|
|||||||
businessUsers BusinessUser[]
|
businessUsers BusinessUser[]
|
||||||
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
||||||
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
|
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
logoDarkMedia Media? @relation("BusinessLogoDark", fields: [logoDarkMediaId], references: [id], onUpdate: NoAction)
|
||||||
carts Cart[]
|
carts Cart[]
|
||||||
categories Category[]
|
categories Category[]
|
||||||
contentCategoryAssignments CategoryAssignment[]
|
contentCategoryAssignments CategoryAssignment[]
|
||||||
@@ -288,6 +290,7 @@ model Media {
|
|||||||
brandImages Brand[]
|
brandImages Brand[]
|
||||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||||
logoBusinesses Business[] @relation("BusinessLogo")
|
logoBusinesses Business[] @relation("BusinessLogo")
|
||||||
|
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
||||||
attachments MediaAttachment[]
|
attachments MediaAttachment[]
|
||||||
|
|||||||
@@ -226,9 +226,33 @@ export class ArvanDnsService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
throw new ServiceUnavailableException('Could not reach ArvanCloud DNS API');
|
const cause =
|
||||||
|
err && typeof err === 'object' && 'cause' in err
|
||||||
|
? (err as { cause?: unknown }).cause
|
||||||
|
: undefined;
|
||||||
|
const detail =
|
||||||
|
(cause &&
|
||||||
|
typeof cause === 'object' &&
|
||||||
|
'code' in cause &&
|
||||||
|
typeof (cause as { code: unknown }).code === 'string' &&
|
||||||
|
(cause as { code: string }).code) ||
|
||||||
|
(cause &&
|
||||||
|
typeof cause === 'object' &&
|
||||||
|
'message' in cause &&
|
||||||
|
typeof (cause as { message: unknown }).message === 'string' &&
|
||||||
|
(cause as { message: string }).message) ||
|
||||||
|
(err instanceof Error ? err.message : null);
|
||||||
|
this.logger.warn(
|
||||||
|
`Arvan DNS request failed ${method} ${path}${detail ? `: ${detail}` : ''}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
detail
|
||||||
|
? `Could not reach ArvanCloud DNS API (${detail})`
|
||||||
|
: 'Could not reach ArvanCloud DNS API',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
|
|||||||
+73
-24
@@ -30,6 +30,8 @@ import { parseUserProfile } from './profile.util';
|
|||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
|
||||||
const OTP_TTL_SECONDS = 300;
|
const OTP_TTL_SECONDS = 300;
|
||||||
|
/** Pending cross-site password change (longer than OTP so resend still works). */
|
||||||
|
const PENDING_PASSWORD_TTL_SECONDS = 600;
|
||||||
const HANDOFF_TTL_SECONDS = 60;
|
const HANDOFF_TTL_SECONDS = 60;
|
||||||
const HANDOFF_RATE_LIMIT = 10;
|
const HANDOFF_RATE_LIMIT = 10;
|
||||||
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
||||||
@@ -78,13 +80,14 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let passwordMatchesExisting = false;
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
const passwordValid = await bcrypt.compare(
|
passwordMatchesExisting = await bcrypt.compare(
|
||||||
dto.password,
|
dto.password,
|
||||||
existingUser.passwordHash,
|
existingUser.passwordHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!passwordValid && !dto.acknowledgeExistingAccount) {
|
if (!passwordMatchesExisting && !dto.acknowledgeExistingAccount) {
|
||||||
const otherNames = existingUser.businessCustomers
|
const otherNames = existingUser.businessCustomers
|
||||||
.map((item) => item.business.nameFa?.trim() || item.business.name)
|
.map((item) => item.business.nameFa?.trim() || item.business.name)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -98,18 +101,49 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkedExistingAccount = Boolean(existingUser);
|
const linkedExistingAccount = Boolean(existingUser);
|
||||||
|
// New password claimed on another site — apply only after OTP proves ownership.
|
||||||
|
const wantsPasswordUpdate =
|
||||||
|
linkedExistingAccount && !passwordMatchesExisting;
|
||||||
|
|
||||||
|
// Hold new password in Redis until OTP; do not touch DB password or membership yet.
|
||||||
|
if (existingUser && wantsPasswordUpdate && smsEnabled) {
|
||||||
|
await this.redis.setPendingPasswordUpdate(
|
||||||
|
dto.cellNumber,
|
||||||
|
{
|
||||||
|
passwordHash,
|
||||||
|
businessId: business.id.toString(),
|
||||||
|
},
|
||||||
|
PENDING_PASSWORD_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const authUser = await this.getAuthUser(existingUser.id);
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
'Please verify your cell number with OTP. Your Meshkee password will be updated after verification.',
|
||||||
|
smsEnabled: true,
|
||||||
|
requiresOtp: true,
|
||||||
|
passwordPending: true,
|
||||||
|
passwordUpdated: false,
|
||||||
|
user: this.serializeUser(authUser),
|
||||||
|
registeredBusiness: {
|
||||||
|
id: business.id,
|
||||||
|
name: business.name,
|
||||||
|
slug: business.slug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const userId = await this.prisma.$transaction(async (tx) => {
|
const userId = await this.prisma.$transaction(async (tx) => {
|
||||||
let userId: bigint;
|
let userId: bigint;
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// Cross-site join: replace Meshkee password with the one entered on this signup.
|
if (wantsPasswordUpdate) {
|
||||||
const updated = await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: existingUser.id },
|
where: { id: existingUser.id },
|
||||||
data: { passwordHash },
|
data: { passwordHash },
|
||||||
select: { id: true },
|
});
|
||||||
});
|
}
|
||||||
userId = updated.id;
|
userId = existingUser.id;
|
||||||
} else {
|
} else {
|
||||||
const created = await tx.user.create({
|
const created = await tx.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -157,15 +191,15 @@ export class AuthService {
|
|||||||
const tokens = await this.issueTokens(authUser);
|
const tokens = await this.issueTokens(authUser);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: linkedExistingAccount
|
message: wantsPasswordUpdate
|
||||||
? smsEnabled
|
? 'Joined this website. Your Meshkee password was updated to the one you just entered.'
|
||||||
? 'Joined this website. Your Meshkee password was updated to the one you just entered. Please verify your cell number with OTP.'
|
: linkedExistingAccount
|
||||||
: 'Joined this website. Your Meshkee password was updated to the one you just entered.'
|
? 'Joined this website with your existing Meshkee account.'
|
||||||
: smsEnabled
|
: smsEnabled
|
||||||
? 'Registration successful. Please verify your cell number with OTP.'
|
? 'Registration successful. Please verify your cell number with OTP.'
|
||||||
: 'Registration successful. SMS verification is disabled — account auto-verified.',
|
: 'Registration successful. SMS verification is disabled — account auto-verified.',
|
||||||
smsEnabled,
|
smsEnabled,
|
||||||
passwordUpdated: linkedExistingAccount,
|
passwordUpdated: wantsPasswordUpdate,
|
||||||
user: this.serializeUser(authUser),
|
user: this.serializeUser(authUser),
|
||||||
registeredBusiness: {
|
registeredBusiness: {
|
||||||
id: business.id,
|
id: business.id,
|
||||||
@@ -439,10 +473,8 @@ export class AuthService {
|
|||||||
|
|
||||||
async loginWithOtp(cellNumber: string, code: string, domain?: string) {
|
async loginWithOtp(cellNumber: string, code: string, domain?: string) {
|
||||||
const user = await this.consumeOtp(cellNumber, code);
|
const user = await this.consumeOtp(cellNumber, code);
|
||||||
|
const pending = await this.redis.consumePendingPasswordUpdate(cellNumber);
|
||||||
if (domain?.trim()) {
|
const passwordUpdated = Boolean(pending?.passwordHash);
|
||||||
await this.ensureCustomerMembershipForDomain(user.id, domain.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
@@ -451,14 +483,28 @@ export class AuthService {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
|
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
|
||||||
lastLoginAt: new Date(),
|
lastLoginAt: new Date(),
|
||||||
|
...(pending?.passwordHash
|
||||||
|
? { passwordHash: pending.passwordHash }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pending?.businessId) {
|
||||||
|
await this.ensureCustomerMembership(user.id, BigInt(pending.businessId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain?.trim()) {
|
||||||
|
await this.ensureCustomerMembershipForDomain(user.id, domain.trim());
|
||||||
|
}
|
||||||
|
|
||||||
const authUser = await this.getAuthUser(user.id);
|
const authUser = await this.getAuthUser(user.id);
|
||||||
const tokens = await this.issueTokens(authUser);
|
const tokens = await this.issueTokens(authUser);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Login successful',
|
message: passwordUpdated
|
||||||
|
? 'Login successful. Your Meshkee password was updated.'
|
||||||
|
: 'Login successful',
|
||||||
|
passwordUpdated,
|
||||||
user: this.serializeUser(authUser),
|
user: this.serializeUser(authUser),
|
||||||
...tokens,
|
...tokens,
|
||||||
};
|
};
|
||||||
@@ -510,17 +556,20 @@ export class AuthService {
|
|||||||
domain: string,
|
domain: string,
|
||||||
) {
|
) {
|
||||||
const business = await this.tenant.resolveBusinessByDomain(domain);
|
const business = await this.tenant.resolveBusinessByDomain(domain);
|
||||||
|
await this.ensureCustomerMembership(userId, business.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCustomerMembership(userId: bigint, businessId: bigint) {
|
||||||
const existing = await this.prisma.businessCustomer.findUnique({
|
const existing = await this.prisma.businessCustomer.findUnique({
|
||||||
where: {
|
where: {
|
||||||
businessId_userId: { businessId: business.id, userId },
|
businessId_userId: { businessId, userId },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await this.prisma.businessCustomer.create({
|
await this.prisma.businessCustomer.create({
|
||||||
data: {
|
data: {
|
||||||
businessId: business.id,
|
businessId,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -489,14 +489,18 @@ export class LegacyPurgeService {
|
|||||||
id: businessId,
|
id: businessId,
|
||||||
OR: [
|
OR: [
|
||||||
{ logoMediaId: { in: uniqueIds } },
|
{ logoMediaId: { in: uniqueIds } },
|
||||||
|
{ logoDarkMediaId: { in: uniqueIds } },
|
||||||
{ faviconMediaId: { in: uniqueIds } },
|
{ faviconMediaId: { in: uniqueIds } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: { logoMediaId: true, faviconMediaId: true },
|
select: { logoMediaId: true, logoDarkMediaId: true, faviconMediaId: true },
|
||||||
});
|
});
|
||||||
if (businessMedia?.logoMediaId != null) {
|
if (businessMedia?.logoMediaId != null) {
|
||||||
stillLinked.add(businessMedia.logoMediaId.toString());
|
stillLinked.add(businessMedia.logoMediaId.toString());
|
||||||
}
|
}
|
||||||
|
if (businessMedia?.logoDarkMediaId != null) {
|
||||||
|
stillLinked.add(businessMedia.logoDarkMediaId.toString());
|
||||||
|
}
|
||||||
if (businessMedia?.faviconMediaId != null) {
|
if (businessMedia?.faviconMediaId != null) {
|
||||||
stillLinked.add(businessMedia.faviconMediaId.toString());
|
stillLinked.add(businessMedia.faviconMediaId.toString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export class BusinessProfileService {
|
|||||||
categoryAssignments: true,
|
categoryAssignments: true,
|
||||||
addresses: { orderBy: { createdAt: 'asc' } },
|
addresses: { orderBy: { createdAt: 'asc' } },
|
||||||
logoMedia: true,
|
logoMedia: true,
|
||||||
|
logoDarkMedia: true,
|
||||||
faviconMedia: true,
|
faviconMedia: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -99,6 +100,7 @@ export class BusinessProfileService {
|
|||||||
categoryAssignments: true,
|
categoryAssignments: true,
|
||||||
addresses: { orderBy: { createdAt: 'asc' } },
|
addresses: { orderBy: { createdAt: 'asc' } },
|
||||||
logoMedia: true,
|
logoMedia: true,
|
||||||
|
logoDarkMedia: true,
|
||||||
faviconMedia: true,
|
faviconMedia: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -140,6 +142,7 @@ export class BusinessProfileService {
|
|||||||
categoryAssignments: true,
|
categoryAssignments: true,
|
||||||
addresses: true,
|
addresses: true,
|
||||||
logoMedia: true,
|
logoMedia: true,
|
||||||
|
logoDarkMedia: true,
|
||||||
faviconMedia: true,
|
faviconMedia: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -156,6 +159,10 @@ export class BusinessProfileService {
|
|||||||
await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId));
|
await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.logoDarkMediaId !== undefined && dto.logoDarkMediaId !== null) {
|
||||||
|
await this.assertLogoMedia(businessId, BigInt(dto.logoDarkMediaId));
|
||||||
|
}
|
||||||
|
|
||||||
const previousFaviconMediaId = business.faviconMediaId;
|
const previousFaviconMediaId = business.faviconMediaId;
|
||||||
const logoChanged =
|
const logoChanged =
|
||||||
dto.logoMediaId !== undefined &&
|
dto.logoMediaId !== undefined &&
|
||||||
@@ -196,6 +203,13 @@ export class BusinessProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.logoDarkMediaId !== undefined) {
|
||||||
|
data.logoDarkMedia =
|
||||||
|
dto.logoDarkMediaId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { connect: { id: BigInt(dto.logoDarkMediaId) } };
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(data).length > 0) {
|
if (Object.keys(data).length > 0) {
|
||||||
await tx.business.update({
|
await tx.business.update({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
@@ -376,6 +390,8 @@ export class BusinessProfileService {
|
|||||||
socialMedia: unknown;
|
socialMedia: unknown;
|
||||||
logoMediaId: bigint | null;
|
logoMediaId: bigint | null;
|
||||||
logoMedia: { publicUrl: string } | null;
|
logoMedia: { publicUrl: string } | null;
|
||||||
|
logoDarkMediaId: bigint | null;
|
||||||
|
logoDarkMedia: { publicUrl: string } | null;
|
||||||
faviconMediaId: bigint | null;
|
faviconMediaId: bigint | null;
|
||||||
faviconMedia: { publicUrl: string } | null;
|
faviconMedia: { publicUrl: string } | null;
|
||||||
categoryAssignments: { categoryId: bigint }[];
|
categoryAssignments: { categoryId: bigint }[];
|
||||||
@@ -390,6 +406,8 @@ export class BusinessProfileService {
|
|||||||
socialMedia: normalizeSocialMedia(business.socialMedia),
|
socialMedia: normalizeSocialMedia(business.socialMedia),
|
||||||
logoMediaId: business.logoMediaId?.toString() ?? null,
|
logoMediaId: business.logoMediaId?.toString() ?? null,
|
||||||
logoUrl: business.logoMedia?.publicUrl ?? null,
|
logoUrl: business.logoMedia?.publicUrl ?? null,
|
||||||
|
logoDarkMediaId: business.logoDarkMediaId?.toString() ?? null,
|
||||||
|
logoDarkUrl: business.logoDarkMedia?.publicUrl ?? null,
|
||||||
faviconMediaId: business.faviconMediaId?.toString() ?? null,
|
faviconMediaId: business.faviconMediaId?.toString() ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
business.faviconMedia?.publicUrl ??
|
business.faviconMedia?.publicUrl ??
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ export type BusinessProfile = {
|
|||||||
socialMedia: BusinessSocialMedia;
|
socialMedia: BusinessSocialMedia;
|
||||||
logoMediaId: string | null;
|
logoMediaId: string | null;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
|
logoDarkMediaId: string | null;
|
||||||
|
logoDarkUrl: string | null;
|
||||||
faviconMediaId: string | null;
|
faviconMediaId: string | null;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
categoryIds: string[];
|
categoryIds: string[];
|
||||||
|
|||||||
@@ -110,6 +110,11 @@ export class UpdateBusinessProfileDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
logoMediaId?: number | null;
|
logoMediaId?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
logoDarkMediaId?: number | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
@@ -199,6 +199,18 @@ export type BusinessSettings = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||||
|
{
|
||||||
|
id: 'awaiting-payment',
|
||||||
|
label: 'Waiting for payment',
|
||||||
|
labelFa: 'در انتظار پرداخت',
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'payment-successful',
|
||||||
|
label: 'Payment successful',
|
||||||
|
labelFa: 'پرداخت موفق',
|
||||||
|
color: '#10B981',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'processing',
|
id: 'processing',
|
||||||
label: 'Under processing',
|
label: 'Under processing',
|
||||||
|
|||||||
@@ -320,6 +320,19 @@ export function listPublicPaymentGateways(settings: EPaymentSettings) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PAYMENT_PROCESS_STEP_IDS = ['awaiting-payment', 'payment-successful'] as const;
|
||||||
|
|
||||||
|
/** Prepend default payment steps when an older store settings JSON omits them. */
|
||||||
|
function ensurePaymentProcessSteps(steps: OrderProcessStep[]): OrderProcessStep[] {
|
||||||
|
const missing = DEFAULT_ORDER_PROCESS_STEPS.filter(
|
||||||
|
(defaults) =>
|
||||||
|
PAYMENT_PROCESS_STEP_IDS.includes(
|
||||||
|
defaults.id as (typeof PAYMENT_PROCESS_STEP_IDS)[number],
|
||||||
|
) && !steps.some((step) => step.id === defaults.id),
|
||||||
|
);
|
||||||
|
return missing.length ? [...missing, ...steps] : steps;
|
||||||
|
}
|
||||||
|
|
||||||
function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
|
function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
|
||||||
if (!Array.isArray(value)) {
|
if (!Array.isArray(value)) {
|
||||||
return DEFAULT_ORDER_PROCESS_STEPS;
|
return DEFAULT_ORDER_PROCESS_STEPS;
|
||||||
@@ -351,7 +364,9 @@ function readOrderProcessSteps(value: unknown): OrderProcessStep[] {
|
|||||||
})
|
})
|
||||||
.filter((step): step is OrderProcessStep => step !== null);
|
.filter((step): step is OrderProcessStep => step !== null);
|
||||||
|
|
||||||
return steps.length ? steps : DEFAULT_ORDER_PROCESS_STEPS;
|
return steps.length
|
||||||
|
? ensurePaymentProcessSteps(steps)
|
||||||
|
: DEFAULT_ORDER_PROCESS_STEPS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ export const ORDER_STEP_COLOR_HEXES = [
|
|||||||
export const DEFAULT_ORDER_STEP_COLOR = '#3B82F6';
|
export const DEFAULT_ORDER_STEP_COLOR = '#3B82F6';
|
||||||
|
|
||||||
const DEFAULT_STEP_COLORS_BY_ID: Record<string, string> = {
|
const DEFAULT_STEP_COLORS_BY_ID: Record<string, string> = {
|
||||||
|
'awaiting-payment': '#64748B',
|
||||||
|
'payment-successful': '#10B981',
|
||||||
processing: '#3B82F6',
|
processing: '#3B82F6',
|
||||||
'ready-for-shipping': '#F59E0B',
|
'ready-for-shipping': '#F59E0B',
|
||||||
shipped: '#8B5CF6',
|
shipped: '#8B5CF6',
|
||||||
|
|||||||
@@ -267,10 +267,11 @@ export class MediaService {
|
|||||||
id: businessId,
|
id: businessId,
|
||||||
OR: [
|
OR: [
|
||||||
{ logoMediaId: { in: uniqueIds } },
|
{ logoMediaId: { in: uniqueIds } },
|
||||||
|
{ logoDarkMediaId: { in: uniqueIds } },
|
||||||
{ faviconMediaId: { in: uniqueIds } },
|
{ faviconMediaId: { in: uniqueIds } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: { logoMediaId: true, faviconMediaId: true },
|
select: { logoMediaId: true, logoDarkMediaId: true, faviconMediaId: true },
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -285,6 +286,7 @@ export class MediaService {
|
|||||||
for (const row of sliders) addId(row.image_media_id);
|
for (const row of sliders) addId(row.image_media_id);
|
||||||
for (const row of staticItems) addId(row.image_media_id);
|
for (const row of staticItems) addId(row.image_media_id);
|
||||||
addId(business?.logoMediaId);
|
addId(business?.logoMediaId);
|
||||||
|
addId(business?.logoDarkMediaId);
|
||||||
addId(business?.faviconMediaId);
|
addId(business?.faviconMediaId);
|
||||||
|
|
||||||
const deletableIds = uniqueIds.filter((id) => !stillLinked.has(id.toString()));
|
const deletableIds = uniqueIds.filter((id) => !stillLinked.has(id.toString()));
|
||||||
|
|||||||
@@ -22,6 +22,54 @@ export class RedisService {
|
|||||||
await this.redis.del(`otp:${cellNumber}`);
|
await this.redis.del(`otp:${cellNumber}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-site signup with a new password: hold the hash until OTP proves
|
||||||
|
* phone ownership, then apply it in login-otp.
|
||||||
|
*/
|
||||||
|
async setPendingPasswordUpdate(
|
||||||
|
cellNumber: string,
|
||||||
|
payload: { passwordHash: string; businessId: string },
|
||||||
|
ttlSeconds: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.redis.set(
|
||||||
|
`auth:pending-password:${cellNumber}`,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
'EX',
|
||||||
|
ttlSeconds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async consumePendingPasswordUpdate(
|
||||||
|
cellNumber: string,
|
||||||
|
): Promise<{ passwordHash: string; businessId: string } | null> {
|
||||||
|
const key = `auth:pending-password:${cellNumber}`;
|
||||||
|
const result = await this.redis.multi().get(key).del(key).exec();
|
||||||
|
const raw = result?.[0]?.[1];
|
||||||
|
if (typeof raw !== 'string' || !raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as {
|
||||||
|
passwordHash?: unknown;
|
||||||
|
businessId?: unknown;
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
typeof parsed.passwordHash !== 'string' ||
|
||||||
|
typeof parsed.businessId !== 'string' ||
|
||||||
|
!parsed.passwordHash ||
|
||||||
|
!parsed.businessId
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
passwordHash: parsed.passwordHash,
|
||||||
|
businessId: parsed.businessId,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async setHandoffTicket(
|
async setHandoffTicket(
|
||||||
ticket: string,
|
ticket: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export class TenantService {
|
|||||||
where: { id: business.id },
|
where: { id: business.id },
|
||||||
select: {
|
select: {
|
||||||
logoMedia: { select: { publicUrl: true } },
|
logoMedia: { select: { publicUrl: true } },
|
||||||
|
logoDarkMedia: { select: { publicUrl: true } },
|
||||||
faviconMedia: { select: { publicUrl: true } },
|
faviconMedia: { select: { publicUrl: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -61,6 +62,7 @@ export class TenantService {
|
|||||||
homeCharts: settings.modules.charts,
|
homeCharts: settings.modules.charts,
|
||||||
specialProductsSource: settings.website.specialProductsSource,
|
specialProductsSource: settings.website.specialProductsSource,
|
||||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||||
|
logoDarkUrl: media?.logoDarkMedia?.publicUrl ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
media?.faviconMedia?.publicUrl ?? media?.logoMedia?.publicUrl ?? null,
|
||||||
ePayment: listPublicPaymentGateways(settings.store.ePayment),
|
ePayment: listPublicPaymentGateways(settings.store.ePayment),
|
||||||
|
|||||||
@@ -169,6 +169,11 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
"logoDarkUrl": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Optional logo for dark backgrounds; falls back to logoUrl when omitted."
|
||||||
|
},
|
||||||
"faviconUrl": {
|
"faviconUrl": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export class WebsiteBusinessInfoService {
|
|||||||
where: { id: business.id },
|
where: { id: business.id },
|
||||||
include: {
|
include: {
|
||||||
logoMedia: true,
|
logoMedia: true,
|
||||||
|
logoDarkMedia: true,
|
||||||
faviconMedia: true,
|
faviconMedia: true,
|
||||||
addresses: { orderBy: { createdAt: 'asc' } },
|
addresses: { orderBy: { createdAt: 'asc' } },
|
||||||
},
|
},
|
||||||
@@ -37,6 +38,7 @@ export class WebsiteBusinessInfoService {
|
|||||||
about: record.about ?? '',
|
about: record.about ?? '',
|
||||||
vision: record.vision ?? '',
|
vision: record.vision ?? '',
|
||||||
logoUrl: record.logoMedia?.publicUrl ?? null,
|
logoUrl: record.logoMedia?.publicUrl ?? null,
|
||||||
|
logoDarkUrl: record.logoDarkMedia?.publicUrl ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
|
record.faviconMedia?.publicUrl ?? record.logoMedia?.publicUrl ?? null,
|
||||||
emails: normalizeEmails(record.emails),
|
emails: normalizeEmails(record.emails),
|
||||||
|
|||||||
Reference in New Issue
Block a user