Initial commit of Balout Pastry public website.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+170
@@ -0,0 +1,170 @@
|
||||
import { getAccessToken } from './auth'
|
||||
import type { CategoryNode, Product, ProductListResponse } from './types'
|
||||
|
||||
const API_BASE =
|
||||
import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, '') ||
|
||||
'http://localhost:3100/api/v1'
|
||||
|
||||
export type ListProductsParams = {
|
||||
q?: string
|
||||
categoryId?: string
|
||||
categorySlug?: string
|
||||
minPrice?: number
|
||||
maxPrice?: number
|
||||
page?: number
|
||||
pageSize?: number
|
||||
}
|
||||
|
||||
export type UserAddress = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
}
|
||||
|
||||
export type Branch = {
|
||||
id: string
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
cellNumber: string
|
||||
}
|
||||
|
||||
export type ShippingException = {
|
||||
id: string
|
||||
district: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type Discount = {
|
||||
id: string
|
||||
code: string
|
||||
percent: number
|
||||
maxValue: number
|
||||
minOrderAmount: number
|
||||
expiresAt: string
|
||||
active: boolean
|
||||
expired?: boolean
|
||||
categoryId?: string | null
|
||||
}
|
||||
|
||||
export type CreateMyOrderPayload = {
|
||||
deliveryType: 'pickup' | 'shipping'
|
||||
branchId?: string
|
||||
shippingAddressId?: string
|
||||
note?: string
|
||||
discountCode?: string
|
||||
items: Array<{
|
||||
productId: string
|
||||
quantity: number
|
||||
optionIds?: string[]
|
||||
}>
|
||||
}
|
||||
|
||||
async function parseError(response: Response) {
|
||||
const text = await response.text().catch(() => '')
|
||||
try {
|
||||
const json = JSON.parse(text) as { message?: string | string[] }
|
||||
if (Array.isArray(json.message)) return json.message.join('، ')
|
||||
if (typeof json.message === 'string') return json.message
|
||||
} catch {
|
||||
// plain text
|
||||
}
|
||||
return text || `Request failed (${response.status})`
|
||||
}
|
||||
|
||||
async function apiGet<T>(path: string, auth = false): Promise<T> {
|
||||
const headers: HeadersInit = {}
|
||||
if (auth) {
|
||||
const token = getAccessToken()
|
||||
if (!token) throw new Error('لطفاً وارد حساب کاربری شوید')
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
const response = await fetch(`${API_BASE}${path}`, { headers })
|
||||
if (!response.ok) throw new Error(await parseError(response))
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
async function apiSend<T>(
|
||||
path: string,
|
||||
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const token = getAccessToken()
|
||||
if (!token) throw new Error('لطفاً وارد حساب کاربری شوید')
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
if (!response.ok) throw new Error(await parseError(response))
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
function buildQuery(params: ListProductsParams) {
|
||||
const search = new URLSearchParams()
|
||||
if (params.q?.trim()) search.set('q', params.q.trim())
|
||||
if (params.categoryId) search.set('categoryId', params.categoryId)
|
||||
if (params.categorySlug) search.set('categorySlug', params.categorySlug)
|
||||
if (params.minPrice !== undefined) search.set('minPrice', String(params.minPrice))
|
||||
if (params.maxPrice !== undefined) search.set('maxPrice', String(params.maxPrice))
|
||||
if (params.page) search.set('page', String(params.page))
|
||||
if (params.pageSize) search.set('pageSize', String(params.pageSize))
|
||||
const qs = search.toString()
|
||||
return qs ? `?${qs}` : ''
|
||||
}
|
||||
|
||||
export function listProducts(params: ListProductsParams = {}) {
|
||||
return apiGet<ProductListResponse>(`/products${buildQuery(params)}`)
|
||||
}
|
||||
|
||||
export function getProduct(id: string) {
|
||||
return apiGet<Product>(`/products/${id}`)
|
||||
}
|
||||
|
||||
export function listCategories() {
|
||||
return apiGet<CategoryNode[]>('/categories')
|
||||
}
|
||||
|
||||
export function listMyAddresses() {
|
||||
return apiGet<UserAddress[]>('/users/me/addresses', true)
|
||||
}
|
||||
|
||||
export function createMyAddress(payload: {
|
||||
name: string
|
||||
district: string
|
||||
address: string
|
||||
landline: string
|
||||
}) {
|
||||
return apiSend<UserAddress>('/users/me/addresses', 'POST', payload)
|
||||
}
|
||||
|
||||
export function listBranches() {
|
||||
return apiGet<Branch[]>('/settings/branches', true)
|
||||
}
|
||||
|
||||
export function listDistricts() {
|
||||
return apiGet<string[]>('/settings/districts', true)
|
||||
}
|
||||
|
||||
export function listShipping() {
|
||||
return apiGet<ShippingException[]>('/settings/shipping', true)
|
||||
}
|
||||
|
||||
export function listMyDiscounts(pageSize = 50) {
|
||||
return apiGet<{ items: Discount[] }>(`/discounts/mine?pageSize=${pageSize}`, true)
|
||||
}
|
||||
|
||||
export function createMyOrder(payload: CreateMyOrderPayload) {
|
||||
return apiSend<{ id: string; code: string; totalPrice: number }>(
|
||||
'/orders/mine',
|
||||
'POST',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
import type { UserRole } from './types'
|
||||
|
||||
const AUTH_COOKIE = 'balout.auth'
|
||||
const AUTH_HASH_PREFIX = '#balout_auth='
|
||||
|
||||
export type AuthUser = {
|
||||
id: string
|
||||
title: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
cellNumber: string
|
||||
role: UserRole
|
||||
name: string
|
||||
}
|
||||
|
||||
export type AuthSession = {
|
||||
accessToken: string
|
||||
refreshToken: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
function isSession(value: unknown): value is AuthSession {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const session = value as Partial<AuthSession>
|
||||
return (
|
||||
typeof session.accessToken === 'string' &&
|
||||
typeof session.refreshToken === 'string' &&
|
||||
!!session.user &&
|
||||
typeof session.user === 'object' &&
|
||||
typeof session.user.name === 'string' &&
|
||||
typeof session.user.id === 'string'
|
||||
)
|
||||
}
|
||||
|
||||
function cookieDomain() {
|
||||
const host = window.location.hostname
|
||||
if (host === 'localhost' || host === '127.0.0.1') return undefined
|
||||
const configured = import.meta.env.VITE_COOKIE_DOMAIN as string | undefined
|
||||
if (configured) return configured
|
||||
if (host.endsWith('.baloutpastry.com') || host === 'baloutpastry.com') {
|
||||
return '.baloutpastry.com'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function readCookie(name: string) {
|
||||
const prefix = `${encodeURIComponent(name)}=`
|
||||
for (const part of document.cookie.split(';')) {
|
||||
const trimmed = part.trim()
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return decodeURIComponent(trimmed.slice(prefix.length))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function writeCookie(name: string, value: string, maxAgeSeconds: number) {
|
||||
const domain = cookieDomain()
|
||||
const parts = [
|
||||
`${encodeURIComponent(name)}=${encodeURIComponent(value)}`,
|
||||
'Path=/',
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
'SameSite=Lax',
|
||||
]
|
||||
if (domain) parts.push(`Domain=${domain}`)
|
||||
document.cookie = parts.join('; ')
|
||||
}
|
||||
|
||||
function clearCookie(name: string) {
|
||||
const domain = cookieDomain()
|
||||
const parts = [
|
||||
`${encodeURIComponent(name)}=`,
|
||||
'Path=/',
|
||||
'Max-Age=0',
|
||||
'SameSite=Lax',
|
||||
]
|
||||
if (domain) parts.push(`Domain=${domain}`)
|
||||
document.cookie = parts.join('; ')
|
||||
}
|
||||
|
||||
export function getSession(): AuthSession | null {
|
||||
try {
|
||||
const raw = readCookie(AUTH_COOKIE)
|
||||
if (!raw) return null
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return isSession(parsed) ? parsed : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function setSession(session: AuthSession) {
|
||||
writeCookie(AUTH_COOKIE, JSON.stringify(session), 60 * 60 * 24 * 7)
|
||||
}
|
||||
|
||||
export function clearSession() {
|
||||
clearCookie(AUTH_COOKIE)
|
||||
}
|
||||
|
||||
export function getAuthUser(): AuthUser | null {
|
||||
return getSession()?.user ?? null
|
||||
}
|
||||
|
||||
export function getAccessToken(): string | null {
|
||||
return getSession()?.accessToken ?? null
|
||||
}
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return getSession() !== null
|
||||
}
|
||||
|
||||
/** Import session handed off from customer login via URL hash. */
|
||||
export function consumeAuthHashFromUrl() {
|
||||
const hash = window.location.hash
|
||||
if (!hash.startsWith(AUTH_HASH_PREFIX)) return false
|
||||
try {
|
||||
const raw = decodeURIComponent(hash.slice(AUTH_HASH_PREFIX.length))
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
if (isSession(parsed)) {
|
||||
setSession(parsed)
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// ignore malformed payload
|
||||
} finally {
|
||||
const clean = `${window.location.pathname}${window.location.search}`
|
||||
window.history.replaceState(null, '', clean)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function getCustomerAppUrl() {
|
||||
return (
|
||||
(import.meta.env.VITE_CUSTOMER_APP_URL as string | undefined)?.replace(/\/$/, '') ||
|
||||
'http://customer.baloutpastry.com:5173'
|
||||
)
|
||||
}
|
||||
|
||||
export function getCustomerLoginUrl(returnUrl = window.location.origin + '/') {
|
||||
const url = new URL(`${getCustomerAppUrl()}/login`)
|
||||
url.searchParams.set('returnUrl', returnUrl)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function getCustomerDashboardUrl(path = '/') {
|
||||
const base = getCustomerAppUrl()
|
||||
return `${base}${path.startsWith('/') ? path : `/${path}`}`
|
||||
}
|
||||
|
||||
export function displayName(user: AuthUser) {
|
||||
const short = [user.firstName, user.lastName].filter(Boolean).join(' ').trim()
|
||||
return short || user.name
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
import type { Product, ProductOptionValue, SellUnit } from './types'
|
||||
import { formatPriceFa } from './types'
|
||||
|
||||
const CART_KEY = 'balout.cart'
|
||||
export const CART_EVENT = 'balout:cart-change'
|
||||
export const TOAST_EVENT = 'balout:toast'
|
||||
|
||||
export type CartOption = {
|
||||
id: string
|
||||
name: string
|
||||
price: number
|
||||
}
|
||||
|
||||
export type CartItem = {
|
||||
key: string
|
||||
productId: string
|
||||
nameFa: string
|
||||
imageUrl: string | null
|
||||
sellUnit: SellUnit
|
||||
basePrice: number
|
||||
quantity: number
|
||||
options: CartOption[]
|
||||
}
|
||||
|
||||
export type ToastPayload = {
|
||||
message: string
|
||||
detail?: string
|
||||
}
|
||||
|
||||
function optionLabel(option: ProductOptionValue) {
|
||||
const flavor = option.flavor?.nameFa?.trim()
|
||||
if (flavor && option.amount) return `${flavor} — ${option.amount}`
|
||||
return flavor || option.amount || 'گزینه'
|
||||
}
|
||||
|
||||
function itemKey(productId: string, optionIds: string[]) {
|
||||
const sorted = [...optionIds].sort().join(',')
|
||||
return sorted ? `${productId}::${sorted}` : productId
|
||||
}
|
||||
|
||||
function readCart(): CartItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CART_KEY)
|
||||
if (!raw) return []
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return Array.isArray(parsed) ? (parsed as CartItem[]) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function writeCart(items: CartItem[]) {
|
||||
localStorage.setItem(CART_KEY, JSON.stringify(items))
|
||||
window.dispatchEvent(new CustomEvent(CART_EVENT))
|
||||
}
|
||||
|
||||
export function getCartItems() {
|
||||
return readCart()
|
||||
}
|
||||
|
||||
export function getCartSubtotal() {
|
||||
return readCart().reduce((sum, item) => sum + cartItemLineTotal(item), 0)
|
||||
}
|
||||
|
||||
export function getCartCount() {
|
||||
return readCart().length
|
||||
}
|
||||
|
||||
export function setCartItemQuantity(key: string, quantity: number) {
|
||||
const items = readCart()
|
||||
const item = items.find((row) => row.key === key)
|
||||
if (!item) return
|
||||
const min = item.sellUnit === 'kilo' ? 0.1 : 1
|
||||
if (quantity < min) {
|
||||
writeCart(items.filter((row) => row.key !== key))
|
||||
return
|
||||
}
|
||||
item.quantity = Number(quantity.toFixed(3))
|
||||
writeCart(items)
|
||||
}
|
||||
|
||||
export function removeCartItem(key: string) {
|
||||
writeCart(readCart().filter((item) => item.key !== key))
|
||||
}
|
||||
|
||||
export function clearCart() {
|
||||
writeCart([])
|
||||
}
|
||||
|
||||
export const CART_OPEN_EVENT = 'balout:cart-open'
|
||||
|
||||
export type CartOpenDetail = {
|
||||
step?: 1 | 2 | 3
|
||||
}
|
||||
|
||||
export function openCartDrawer(step: 1 | 2 | 3 = 1) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<CartOpenDetail>(CART_OPEN_EVENT, { detail: { step } }),
|
||||
)
|
||||
}
|
||||
|
||||
/** Login should send users back here to continue checkout. */
|
||||
export function getCartCheckoutReturnUrl() {
|
||||
const url = new URL(window.location.origin)
|
||||
url.searchParams.set('cart', 'checkout')
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
export function cartItemUnitPrice(item: CartItem) {
|
||||
const optionsTotal = item.options.reduce((sum, option) => sum + option.price, 0)
|
||||
return item.basePrice + optionsTotal
|
||||
}
|
||||
|
||||
export function cartItemLineTotal(item: CartItem) {
|
||||
return Math.round(cartItemUnitPrice(item) * item.quantity)
|
||||
}
|
||||
|
||||
export function addToCart(input: {
|
||||
product: Product
|
||||
quantity: number
|
||||
options?: ProductOptionValue[]
|
||||
}) {
|
||||
const options = (input.options ?? []).map((option) => ({
|
||||
id: option.id,
|
||||
name: optionLabel(option),
|
||||
price: option.price,
|
||||
}))
|
||||
const key = itemKey(
|
||||
input.product.id,
|
||||
options.map((option) => option.id),
|
||||
)
|
||||
const items = readCart()
|
||||
const existing = items.find((item) => item.key === key)
|
||||
if (existing) {
|
||||
existing.quantity = Number((existing.quantity + input.quantity).toFixed(3))
|
||||
} else {
|
||||
items.push({
|
||||
key,
|
||||
productId: input.product.id,
|
||||
nameFa: input.product.nameFa,
|
||||
imageUrl: input.product.mainImageUrl,
|
||||
sellUnit: input.product.sellUnit,
|
||||
basePrice: input.product.price,
|
||||
quantity: input.quantity,
|
||||
options,
|
||||
})
|
||||
}
|
||||
writeCart(items)
|
||||
|
||||
const optionsHint =
|
||||
options.length > 0
|
||||
? options.map((option) => option.name).join('، ')
|
||||
: undefined
|
||||
showToast({
|
||||
message: `${input.product.nameFa} به سبد خرید اضافه شد`,
|
||||
detail: optionsHint
|
||||
? `${optionsHint} — ${formatPriceFa(input.quantity)} ${input.product.sellUnit === 'kilo' ? 'کیلو' : 'عدد'}`
|
||||
: `${formatPriceFa(input.quantity)} ${input.product.sellUnit === 'kilo' ? 'کیلو' : 'عدد'}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function showToast(payload: ToastPayload) {
|
||||
window.dispatchEvent(new CustomEvent(TOAST_EVENT, { detail: payload }))
|
||||
}
|
||||
|
||||
export function subscribeCart(listener: () => void) {
|
||||
const handler = () => listener()
|
||||
window.addEventListener(CART_EVENT, handler)
|
||||
window.addEventListener('storage', handler)
|
||||
return () => {
|
||||
window.removeEventListener(CART_EVENT, handler)
|
||||
window.removeEventListener('storage', handler)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
/** Prisma cuid-like ids used for products (not category slugs). */
|
||||
export function isCuid(value: string) {
|
||||
return /^c[a-z0-9]{20,}$/i.test(value)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
export type SellUnit = 'unit' | 'kilo'
|
||||
|
||||
export type UserRole = 'customer' | 'admin' | 'superAdmin'
|
||||
|
||||
export type ProductCategoryRef = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
parentId?: string | null
|
||||
}
|
||||
|
||||
export type ProductOptionValue = {
|
||||
id: string
|
||||
flavorId: string
|
||||
amount: string
|
||||
price: number
|
||||
flavor?: {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ProductGalleryImage = {
|
||||
id?: string
|
||||
url: string
|
||||
storageKey?: string
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type Product = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
price: number
|
||||
sellUnit: SellUnit
|
||||
categoryId: string
|
||||
category: ProductCategoryRef
|
||||
intro?: string | null
|
||||
description?: string | null
|
||||
tags?: string[]
|
||||
mainImageUrl: string | null
|
||||
mainImageKey?: string | null
|
||||
gallery?: ProductGalleryImage[]
|
||||
options?: ProductOptionValue[]
|
||||
}
|
||||
|
||||
export type CategoryNode = {
|
||||
id: string
|
||||
nameFa: string
|
||||
nameEn: string
|
||||
slug: string
|
||||
parentId: string | null
|
||||
sortOrder: number
|
||||
children?: CategoryNode[]
|
||||
}
|
||||
|
||||
export type ProductListResponse = {
|
||||
items: Product[]
|
||||
total: number
|
||||
page: number
|
||||
pageSize: number
|
||||
}
|
||||
|
||||
const sellUnitLabel: Record<SellUnit, string> = {
|
||||
unit: 'واحد',
|
||||
kilo: 'کیلو',
|
||||
}
|
||||
|
||||
export function formatPriceFa(price: number) {
|
||||
return price.toLocaleString('fa-IR')
|
||||
}
|
||||
|
||||
export function formatProductPrice(product: Product) {
|
||||
const unit = sellUnitLabel[product.sellUnit] ?? ''
|
||||
return {
|
||||
amount: formatPriceFa(product.price),
|
||||
unitLabel: unit,
|
||||
full: `${formatPriceFa(product.price)} تومان /${unit}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function productImageSrc(product: Product, fallback: string) {
|
||||
return product.mainImageUrl || fallback
|
||||
}
|
||||
Reference in New Issue
Block a user