Add e-payment UI, checkout payment result, and legacy migrate domain lookup.

Wire Mellat settings in business dashboard, customer bank return page with payment details, and super-admin migrate modal domain confirmation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-13 09:51:22 +03:30
co-authored by Cursor
parent a11f9643bb
commit cc94b929e0
54 changed files with 6415 additions and 96 deletions
+19 -1
View File
@@ -47,7 +47,7 @@ export function mapCommentApiToUi(comment: CommentApi): ProductComment {
}
async function listEntityComments(
entityType: 'product' | 'blog' | 'portfolio',
entityType: 'product' | 'blog' | 'portfolio' | 'video' | 'instruction',
entityId: string,
page = 1,
pageSize = COMMENTS_PER_PAGE,
@@ -98,6 +98,24 @@ export async function listPortfolioComments(
return listEntityComments('portfolio', portfolioId, page, pageSize, signal)
}
export async function listVideoComments(
videoId: string,
page = 1,
pageSize = COMMENTS_PER_PAGE,
signal?: AbortSignal,
) {
return listEntityComments('video', videoId, page, pageSize, signal)
}
export async function listInstructionComments(
instructionId: string,
page = 1,
pageSize = COMMENTS_PER_PAGE,
signal?: AbortSignal,
) {
return listEntityComments('instruction', instructionId, page, pageSize, signal)
}
export async function updateCommentApproval(commentId: string, isApproved: boolean) {
const data = await apiRequest<{ comment: CommentApi }>(commentsPath(`/${commentId}`), {
method: 'PATCH',
@@ -0,0 +1,76 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'instruction' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listInstructionCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createInstructionCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updateInstructionCategory(categoryId: string, payload: UpdateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteInstructionCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export function toInstructionCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapInstructionCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
@@ -0,0 +1,243 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
Instruction,
InstructionAuthor,
InstructionDetail,
InstructionStatus,
VideoProvider,
} from '../types/instruction'
export const INSTRUCTIONS_PER_PAGE = 12
export interface InstructionApi {
id: string
businessId: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
descriptionHtml: string
videoProvider: VideoProvider | null
embedCode: string
status: InstructionStatus
categoryId: string | null
categoryName: string
tags: string[]
coverImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
author?: InstructionAuthor | null
commentCount: number
publishedAt: string | null
createdAt: string
updatedAt: string
}
export interface InstructionsListResponse {
items: InstructionApi[]
total: number
page: number
pageSize: number
}
export interface ListInstructionsParams {
page?: number
pageSize?: number
status?: InstructionStatus
provider?: VideoProvider
categoryId?: string
title?: string
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/instructions${suffix}`
}
export interface CreateInstructionPayload {
titleFa: string
titleEn?: string
abstract?: string
descriptionHtml?: string
videoProvider?: VideoProvider
embedCode?: string
categoryId?: string
status?: InstructionStatus
featuredMediaId?: string
tags?: string[]
}
export interface UpdateInstructionPayload {
titleFa?: string
titleEn?: string | null
abstract?: string | null
descriptionHtml?: string | null
videoProvider?: VideoProvider | null
embedCode?: string | null
categoryId?: string | null
status?: InstructionStatus
featuredMediaId?: string | null
tags?: string[]
}
export function resolveInstructionTitle(
instruction: Pick<Instruction, 'titleFa' | 'titleEn' | 'title'>,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
return instruction.titleFa || instruction.title
}
return instruction.titleEn?.trim() || instruction.titleFa || instruction.title
}
export function mapInstructionApiToUi(instruction: InstructionApi): Instruction {
return {
id: instruction.id,
title: instruction.title,
titleFa: instruction.titleFa || instruction.title,
titleEn: instruction.titleEn,
slug: instruction.slug,
abstract: instruction.abstract,
videoProvider: instruction.videoProvider,
embedCode: instruction.embedCode,
status: instruction.status,
categoryId: instruction.categoryId,
categoryName: instruction.categoryName,
tags: instruction.tags,
coverImageUrl: instruction.coverImageUrl,
commentCount: instruction.commentCount,
author: instruction.author ?? null,
publishedAt: instruction.publishedAt,
createdAt: instruction.createdAt,
updatedAt: instruction.updatedAt,
}
}
export function mapInstructionDetailFromApi(instruction: InstructionApi): InstructionDetail {
return {
...mapInstructionApiToUi(instruction),
descriptionHtml: instruction.descriptionHtml,
author: instruction.author ?? null,
}
}
export function formatInstructionAuthor(author: InstructionAuthor | null): string {
if (!author) return 'Unknown author'
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
return name || author.email || 'Unknown author'
}
export function formatInstructionDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatInstructionCardDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function mapInstructionApiToFormState(instruction: InstructionApi) {
return {
titleFa: instruction.titleFa || instruction.title,
titleEn: instruction.titleEn ?? '',
abstract: instruction.abstract,
descriptionHtml: instruction.descriptionHtml,
videoProvider: instruction.videoProvider ?? 'youtube',
embedCode: instruction.embedCode,
categoryId: instruction.categoryId ?? '',
tags: instruction.tags,
coverImage: instruction.coverImageUrl,
featuredMediaId: instruction.featuredMediaId,
}
}
export async function listInstructions(
page = 1,
pageSize = INSTRUCTIONS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListInstructionsParams, 'page' | 'pageSize'>,
) {
const q = new URLSearchParams()
q.set('page', String(page))
q.set('pageSize', String(pageSize))
if (filters?.status) q.set('status', filters.status)
if (filters?.provider) q.set('provider', filters.provider)
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<InstructionsListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapInstructionApiToUi),
}
}
export async function getInstruction(instructionId: string, signal?: AbortSignal) {
const data = await apiRequest<{ instruction: InstructionApi }>(businessPath(`/${instructionId}`), {
auth: true,
signal,
})
return data.instruction
}
export async function getInstructionDetail(instructionId: string, signal?: AbortSignal) {
const instruction = await getInstruction(instructionId, signal)
return mapInstructionDetailFromApi(instruction)
}
export async function createInstruction(payload: CreateInstructionPayload) {
return apiRequest<{ message: string; instruction: InstructionApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateInstruction(
instructionId: string,
payload: UpdateInstructionPayload,
) {
return apiRequest<{ message: string; instruction: InstructionApi }>(
businessPath(`/${instructionId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteInstruction(instructionId: string) {
return apiRequest<{ message: string }>(businessPath(`/${instructionId}`), {
method: 'DELETE',
auth: true,
})
}
@@ -20,9 +20,60 @@ export interface OrderProcessStep {
color: string
}
export type PaymentGatewayId =
| 'mellat'
| 'sep'
| 'snappay'
| 'digipay'
| 'zarinpal'
export interface MellatGatewaySettings {
enabled: boolean
terminalId: string
username: string
/** Always empty in API responses; use passwordSet. */
password: string
passwordSet?: boolean
}
export interface StubGatewaySettings {
enabled: boolean
}
export interface EPaymentSettings {
enabled: boolean
defaultGateway: PaymentGatewayId | null
gateways: {
mellat: MellatGatewaySettings
sep: StubGatewaySettings
snappay: StubGatewaySettings
digipay: StubGatewaySettings
zarinpal: StubGatewaySettings
}
}
export interface StoreSettings {
onlineSellEnabled: boolean
orderProcessSteps: OrderProcessStep[]
ePayment: EPaymentSettings
}
export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
enabled: false,
defaultGateway: null,
gateways: {
mellat: {
enabled: false,
terminalId: '',
username: '',
password: '',
passwordSet: false,
},
sep: { enabled: false },
snappay: { enabled: false },
digipay: { enabled: false },
zarinpal: { enabled: false },
},
}
export interface ModulesSettings {
@@ -0,0 +1,76 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
CategoryFormData,
CreateCategoryPayload,
ProductCategoriesResponse,
ProductCategory,
UpdateCategoryPayload,
} from '../types/category'
const ENTITY_TYPE = 'video' as const
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/categories${suffix}`
}
export async function listVideoCategories(signal?: AbortSignal) {
const data = await apiRequest<ProductCategoriesResponse>(
`${businessPath()}?entityType=${ENTITY_TYPE}`,
{ auth: true, signal },
)
return data.items
}
export async function createVideoCategory(payload: CreateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(businessPath(), {
method: 'POST',
auth: true,
body: { ...payload, entityType: ENTITY_TYPE },
})
}
export async function updateVideoCategory(categoryId: string, payload: UpdateCategoryPayload) {
return apiRequest<{ message: string; category: ProductCategory }>(
businessPath(`/${categoryId}`),
{
method: 'PATCH',
auth: true,
body: payload,
},
)
}
export async function deleteVideoCategory(categoryId: string) {
return apiRequest<{ message: string; deletedIds: string[] }>(
businessPath(`/${categoryId}`),
{
method: 'DELETE',
auth: true,
},
)
}
export function toVideoCategoryFormPayload(data: CategoryFormData): CreateCategoryPayload {
return {
parentId: data.parentId || undefined,
name: data.nameEn,
nameFa: data.nameFa,
description: data.description || undefined,
}
}
export function mapVideoCategoryToUi(category: ProductCategory) {
return {
id: category.id,
nameEn: category.name,
nameFa: category.nameFa ?? '',
description: category.description ?? '',
parentId: category.parentId,
variationCount: category.variationCount ?? 0,
}
}
+237
View File
@@ -0,0 +1,237 @@
import { apiRequest } from '../lib/api'
import { getActiveBusinessId } from '../lib/businessContext'
import type {
Video,
VideoAuthor,
VideoDetail,
VideoProvider,
VideoStatus,
} from '../types/video'
export const VIDEOS_PER_PAGE = 12
export interface VideoApi {
id: string
businessId: string
title: string
titleFa: string
titleEn: string | null
slug: string
abstract: string
descriptionHtml: string
videoProvider: VideoProvider
embedCode: string
status: VideoStatus
categoryId: string | null
categoryName: string
tags: string[]
coverImageUrl: string | null
featuredMediaId: string | null
authorId?: string | null
author?: VideoAuthor | null
commentCount: number
publishedAt: string | null
createdAt: string
updatedAt: string
}
export interface VideosListResponse {
items: VideoApi[]
total: number
page: number
pageSize: number
}
export interface ListVideosParams {
page?: number
pageSize?: number
status?: VideoStatus
provider?: VideoProvider
categoryId?: string
title?: string
}
function businessPath(suffix = '') {
const businessId = getActiveBusinessId()
if (!businessId) {
throw new Error('No active business selected. Please sign in again.')
}
return `/businesses/${businessId}/videos${suffix}`
}
export interface CreateVideoPayload {
titleFa: string
titleEn?: string
abstract?: string
descriptionHtml?: string
videoProvider: VideoProvider
embedCode: string
categoryId?: string
status?: VideoStatus
featuredMediaId?: string
tags?: string[]
}
export interface UpdateVideoPayload {
titleFa?: string
titleEn?: string | null
abstract?: string | null
descriptionHtml?: string | null
videoProvider?: VideoProvider
embedCode?: string
categoryId?: string | null
status?: VideoStatus
featuredMediaId?: string | null
tags?: string[]
}
export function resolveVideoTitle(
video: Pick<Video, 'titleFa' | 'titleEn' | 'title'>,
locale: 'en' | 'fa' = 'en',
): string {
if (locale === 'fa') {
return video.titleFa || video.title
}
return video.titleEn?.trim() || video.titleFa || video.title
}
export function mapVideoApiToUi(video: VideoApi): Video {
return {
id: video.id,
title: video.title,
titleFa: video.titleFa || video.title,
titleEn: video.titleEn,
slug: video.slug,
abstract: video.abstract,
videoProvider: video.videoProvider,
embedCode: video.embedCode,
status: video.status,
categoryId: video.categoryId,
categoryName: video.categoryName,
tags: video.tags,
coverImageUrl: video.coverImageUrl,
commentCount: video.commentCount,
author: video.author ?? null,
publishedAt: video.publishedAt,
createdAt: video.createdAt,
updatedAt: video.updatedAt,
}
}
export function mapVideoDetailFromApi(video: VideoApi): VideoDetail {
return {
...mapVideoApiToUi(video),
descriptionHtml: video.descriptionHtml,
author: video.author ?? null,
}
}
export function formatVideoAuthor(author: VideoAuthor | null): string {
if (!author) return 'Unknown author'
const name = [author.firstName, author.lastName].filter(Boolean).join(' ').trim()
return name || author.email || 'Unknown author'
}
export function formatVideoDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return '—'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function formatVideoCardDate(
value: string | null,
locale: 'en' | 'fa' = 'en',
): string {
if (!value) return locale === 'fa' ? 'منتشر نشده' : 'Not published'
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
return date.toLocaleDateString(locale === 'fa' ? 'fa-IR' : 'en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export function mapVideoApiToFormState(video: VideoApi) {
return {
titleFa: video.titleFa || video.title,
titleEn: video.titleEn ?? '',
abstract: video.abstract,
descriptionHtml: video.descriptionHtml,
videoProvider: video.videoProvider,
embedCode: video.embedCode,
categoryId: video.categoryId ?? '',
tags: video.tags,
coverImage: video.coverImageUrl,
featuredMediaId: video.featuredMediaId,
}
}
export async function listVideos(
page = 1,
pageSize = VIDEOS_PER_PAGE,
signal?: AbortSignal,
filters?: Omit<ListVideosParams, 'page' | 'pageSize'>,
) {
const q = new URLSearchParams()
q.set('page', String(page))
q.set('pageSize', String(pageSize))
if (filters?.status) q.set('status', filters.status)
if (filters?.provider) q.set('provider', filters.provider)
if (filters?.categoryId) q.set('categoryId', filters.categoryId)
if (filters?.title) q.set('title', filters.title)
const data = await apiRequest<VideosListResponse>(`${businessPath()}?${q.toString()}`, {
auth: true,
signal,
})
return {
...data,
items: data.items.map(mapVideoApiToUi),
}
}
export async function getVideo(videoId: string, signal?: AbortSignal) {
const data = await apiRequest<{ video: VideoApi }>(businessPath(`/${videoId}`), {
auth: true,
signal,
})
return data.video
}
export async function getVideoDetail(videoId: string, signal?: AbortSignal) {
const video = await getVideo(videoId, signal)
return mapVideoDetailFromApi(video)
}
export async function createVideo(payload: CreateVideoPayload) {
return apiRequest<{ message: string; video: VideoApi }>(businessPath(), {
method: 'POST',
auth: true,
body: payload,
})
}
export async function updateVideo(videoId: string, payload: UpdateVideoPayload) {
return apiRequest<{ message: string; video: VideoApi }>(businessPath(`/${videoId}`), {
method: 'PATCH',
auth: true,
body: payload,
})
}
export async function deleteVideo(videoId: string) {
return apiRequest<{ message: string }>(businessPath(`/${videoId}`), {
method: 'DELETE',
auth: true,
})
}