Initial commit of Balout Pastry public website.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-05 15:15:01 +03:30
co-authored by Cursor
commit 35939e6ee4
80 changed files with 8796 additions and 0 deletions
+170
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
}
+4
View File
@@ -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)
}
+85
View File
@@ -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
}