Share customer session cookies with the storefront and polish business settings.

Keep JWT login mirrored on the tenant cookie domain, improve e-payment settings UX, and add a storefront link from the customer sidebar.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-25 16:40:52 +03:30
co-authored by Cursor
parent c289de5d2e
commit dc570c1e7f
20 changed files with 463 additions and 159 deletions
@@ -57,6 +57,7 @@
font-size: 13px; font-size: 13px;
color: var(--text-secondary); color: var(--text-secondary);
margin-top: 4px; margin-top: 4px;
text-align: start;
} }
.modalHint { .modalHint {
@@ -122,14 +123,13 @@
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
color: var(--text-primary); color: var(--text-primary);
text-align: start;
} }
.listItemName:global(.faText), .listItemName:global(.faText),
.listItemValues:global(.faText), .listItemValues:global(.faText),
.subtitle:global(.faText) { .subtitle:global(.faText) {
font-weight: 500; font-weight: 500;
text-align: left;
unicode-bidi: plaintext;
} }
.listItemValues { .listItemValues {
@@ -139,6 +139,7 @@
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
text-align: start;
} }
.listItemEnd { .listItemEnd {
@@ -146,7 +147,7 @@
align-items: center; align-items: center;
gap: 8px; gap: 8px;
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; margin-inline-start: auto;
} }
.listItemType { .listItemType {
@@ -124,8 +124,8 @@ export function VariationsModal({
{categoryName && ( {categoryName && (
<p <p
className={[styles.subtitle, categoryLocale.className].filter(Boolean).join(' ')} className={[styles.subtitle, categoryLocale.className].filter(Boolean).join(' ')}
lang={categoryLocale.lang} lang={isFa ? 'fa' : categoryLocale.lang}
dir={categoryLocale.dir} dir={isFa ? 'rtl' : categoryLocale.dir}
> >
{categoryName} {categoryName}
</p> </p>
@@ -149,15 +149,15 @@ export function VariationsModal({
<div className={styles.listItemInfo}> <div className={styles.listItemInfo}>
<div <div
className={[styles.listItemName, nameLocale.className].filter(Boolean).join(' ')} className={[styles.listItemName, nameLocale.className].filter(Boolean).join(' ')}
lang={nameLocale.lang} lang={isFa ? 'fa' : nameLocale.lang}
dir={nameLocale.dir} dir={isFa ? 'rtl' : nameLocale.dir}
> >
{title} {title}
</div> </div>
<div <div
className={[styles.listItemValues, valuesLocale.className].filter(Boolean).join(' ')} className={[styles.listItemValues, valuesLocale.className].filter(Boolean).join(' ')}
lang={valuesLocale.lang} lang={isFa ? 'fa' : valuesLocale.lang}
dir={valuesLocale.dir} dir={isFa ? 'rtl' : valuesLocale.dir}
> >
{valuesText} {valuesText}
</div> </div>
+2 -2
View File
@@ -1441,7 +1441,7 @@ const en = {
'portfolio.form.abstract': 'Abstract', 'portfolio.form.abstract': 'Abstract',
'portfolio.form.abstractPlaceholder': 'Short summary shown in portfolio listings', 'portfolio.form.abstractPlaceholder': 'Short summary shown in portfolio listings',
'portfolio.form.link': 'Website link', 'portfolio.form.link': 'Website link',
'portfolio.form.linkPlaceholder': 'https://example.com', 'portfolio.form.linkPlaceholder': 'example.com',
'portfolio.form.mainText': 'Main Text', 'portfolio.form.mainText': 'Main Text',
'portfolio.form.mainTextPlaceholder': 'Full portfolio content with formatting and images...', 'portfolio.form.mainTextPlaceholder': 'Full portfolio content with formatting and images...',
'portfolio.form.gallery': 'Image Gallery', 'portfolio.form.gallery': 'Image Gallery',
@@ -3624,7 +3624,7 @@ const fa: Record<MessageKey, string> = {
'portfolio.form.abstract': 'خلاصه', 'portfolio.form.abstract': 'خلاصه',
'portfolio.form.abstractPlaceholder': 'خلاصه کوتاه برای نمایش در فهرست نمونه کارها', 'portfolio.form.abstractPlaceholder': 'خلاصه کوتاه برای نمایش در فهرست نمونه کارها',
'portfolio.form.link': 'لینک وبسایت', 'portfolio.form.link': 'لینک وبسایت',
'portfolio.form.linkPlaceholder': 'https://example.com', 'portfolio.form.linkPlaceholder': 'example.com',
'portfolio.form.mainText': 'متن اصلی', 'portfolio.form.mainText': 'متن اصلی',
'portfolio.form.mainTextPlaceholder': 'محتوای کامل نمونه کار با قالب‌بندی و تصاویر...', 'portfolio.form.mainTextPlaceholder': 'محتوای کامل نمونه کار با قالب‌بندی و تصاویر...',
'portfolio.form.gallery': 'گالری تصاویر', 'portfolio.form.gallery': 'گالری تصاویر',
@@ -32,7 +32,7 @@ import {
type UserProductPriceCurrency, type UserProductPriceCurrency,
type UserProductTechnicalValueInput, type UserProductTechnicalValueInput,
} from '../services/userProductsService' } from '../services/userProductsService'
import { resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService' import { galleryMediaIdsAfterChange, resolveDataUrlToMediaId, resolveDataUrlsToMediaIds } from '../services/mediaService'
import { formatIrtInput, parseIrtInput } from '../utils/irtPrice' import { formatIrtInput, parseIrtInput } from '../utils/irtPrice'
import styles from './AddCustomerProductPage.module.css' import styles from './AddCustomerProductPage.module.css'
@@ -744,20 +744,10 @@ export function AddCustomerProductPage() {
<ImageUploader <ImageUploader
images={gallery} images={gallery}
onChange={(next) => { onChange={(next) => {
const prevUrlToId = new Map( setGalleryMediaIds(
gallery.map((url, index) => [ galleryMediaIdsAfterChange(gallery, galleryMediaIds, next),
url,
galleryMediaIds[index] ?? '',
]),
) )
setGallery(next) setGallery(next)
setGalleryMediaIds(
next.map((url) =>
url.startsWith('data:')
? ''
: prevUrlToId.get(url) ?? '',
),
)
}} }}
/> />
</div> </div>
@@ -14,6 +14,7 @@ import {
mapPortfolioCategoryToUi, mapPortfolioCategoryToUi,
} from '../services/portfolioCategoryService' } from '../services/portfolioCategoryService'
import { import {
galleryMediaIdsAfterChange,
resolveDataUrlToMediaId, resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds, resolveDataUrlsToMediaIds,
resolveHtmlEmbeddedDataUrls, resolveHtmlEmbeddedDataUrls,
@@ -329,10 +330,11 @@ export function AddNewPortfolioPage() {
<input <input
id="portfolio-link" id="portfolio-link"
name="projectUrl" name="projectUrl"
type="url" type="text"
dir="ltr" dir="ltr"
lang="en" lang="en"
inputMode="url" inputMode="url"
autoComplete="url"
placeholder={t('portfolio.form.linkPlaceholder')} placeholder={t('portfolio.form.linkPlaceholder')}
value={projectUrl} value={projectUrl}
onChange={(e) => setProjectUrl(e.target.value)} onChange={(e) => setProjectUrl(e.target.value)}
@@ -354,7 +356,15 @@ export function AddNewPortfolioPage() {
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>{t('portfolio.form.gallery')}</label> <label>{t('portfolio.form.gallery')}</label>
<ImageUploader images={images} onChange={setImages} /> <ImageUploader
images={images}
onChange={(next) => {
setGalleryMediaIds(
galleryMediaIdsAfterChange(images, galleryMediaIds, next),
)
setImages(next)
}}
/>
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
+10 -1
View File
@@ -14,6 +14,7 @@ import { ApiError } from '../lib/api'
import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService' import { listProductCategories, mapProductCategoryToUi } from '../services/productCategoryService'
import { listAllBrands, mapBrandToSelectOption } from '../services/brandService' import { listAllBrands, mapBrandToSelectOption } from '../services/brandService'
import { import {
galleryMediaIdsAfterChange,
resolveDataUrlToMediaId, resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds, resolveDataUrlsToMediaIds,
} from '../services/mediaService' } from '../services/mediaService'
@@ -372,7 +373,15 @@ export function AddNewProductPage() {
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>{t('products.form.images')}</label> <label>{t('products.form.images')}</label>
<ImageUploader images={images} onChange={setImages} /> <ImageUploader
images={images}
onChange={(next) => {
setGalleryMediaIds(
galleryMediaIdsAfterChange(images, galleryMediaIds, next),
)
setImages(next)
}}
/>
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
+10 -1
View File
@@ -15,6 +15,7 @@ import {
mapWorkshopCategoryToUi, mapWorkshopCategoryToUi,
} from '../services/workshopCategoryService' } from '../services/workshopCategoryService'
import { import {
galleryMediaIdsAfterChange,
resolveDataUrlToMediaId, resolveDataUrlToMediaId,
resolveDataUrlsToMediaIds, resolveDataUrlsToMediaIds,
resolveHtmlEmbeddedDataUrls, resolveHtmlEmbeddedDataUrls,
@@ -353,7 +354,15 @@ export function AddNewWorkshopPage() {
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
<label>{t('workshop.form.gallery')}</label> <label>{t('workshop.form.gallery')}</label>
<ImageUploader images={images} onChange={setImages} /> <ImageUploader
images={images}
onChange={(next) => {
setGalleryMediaIds(
galleryMediaIdsAfterChange(images, galleryMediaIds, next),
)
setImages(next)
}}
/>
</div> </div>
<div className={`${styles.field} ${styles.col12}`}> <div className={`${styles.field} ${styles.col12}`}>
@@ -43,10 +43,27 @@
.credentials { .credentials {
display: grid; display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr)); grid-template-columns: repeat(12, minmax(0, 1fr));
gap: 12px 16px; gap: 10px 12px;
margin-top: 18px; margin-top: 14px;
padding-top: 18px; padding-top: 14px;
border-top: 1px solid rgba(148, 163, 184, 0.2); border-top: 1px solid rgba(148, 163, 184, 0.2);
align-items: end;
}
.credentialsInline {
display: flex;
align-items: center;
gap: 10px 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid rgba(148, 163, 184, 0.2);
}
.credentialsDisabled {
filter: blur(2px);
opacity: 0.55;
pointer-events: none;
user-select: none;
} }
.field { .field {
@@ -54,8 +71,38 @@
min-width: 0; min-width: 0;
} }
.fullRow { .inlineLabel {
grid-column: 1 / -1; flex-shrink: 0;
font-size: 12px;
font-weight: 500;
color: var(--text-secondary);
white-space: nowrap;
}
.inlineInput {
flex: 1;
min-width: 0;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
font-family: var(--font-ui);
color: var(--text-primary);
background-color: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: var(--radius-sm);
outline: none;
box-sizing: border-box;
}
.inlineInput:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.inlineInput:disabled {
opacity: 0.7;
cursor: not-allowed;
} }
.hint { .hint {
@@ -90,15 +137,22 @@
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
} }
.passwordToggle:hover { .passwordToggle:hover:not(:disabled) {
color: var(--text-primary); color: var(--text-primary);
background: rgba(148, 163, 184, 0.15); background: rgba(148, 163, 184, 0.15);
} }
.actions { .passwordToggle:disabled {
grid-column: 1 / -1; cursor: not-allowed;
opacity: 0.6;
}
.actionsInline {
grid-column: span 3;
display: flex; display: flex;
align-items: flex-end;
justify-content: flex-end; justify-content: flex-end;
min-width: 0;
} }
.saveBtn { .saveBtn {
@@ -112,6 +166,8 @@
font-weight: 600; font-weight: 600;
font-family: var(--font-ui); font-family: var(--font-ui);
cursor: pointer; cursor: pointer;
flex-shrink: 0;
white-space: nowrap;
} }
.saveBtn:disabled { .saveBtn:disabled {
@@ -143,10 +199,35 @@
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.field { .field,
.actionsInline {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
.actionsInline {
justify-content: stretch;
}
.actionsInline .saveBtn {
width: 100%;
}
.credentialsInline {
flex-wrap: wrap;
}
.inlineLabel {
width: 100%;
}
.inlineInput {
flex: 1 1 100%;
}
.credentialsInline .saveBtn {
width: 100%;
}
.gatewayHeader { .gatewayHeader {
flex-direction: column; flex-direction: column;
} }
+67 -99
View File
@@ -104,16 +104,22 @@ export function SettingsPage() {
try { try {
const payload: EPaymentSettings = { const payload: EPaymentSettings = {
...next, enabled: next.enabled,
defaultGateway: next.defaultGateway,
gateways: { gateways: {
...next.gateways,
mellat: { mellat: {
...next.gateways.mellat, enabled: next.gateways.mellat.enabled,
terminalId: next.gateways.mellat.terminalId,
username: next.gateways.mellat.username,
password: password ?? '', password: password ?? '',
}, },
sep: { enabled: next.gateways.sep.enabled },
snappay: { enabled: next.gateways.snappay.enabled },
digipay: { enabled: next.gateways.digipay.enabled },
zarinpal: { zarinpal: {
...next.gateways.zarinpal, enabled: next.gateways.zarinpal.enabled,
merchantId: next.gateways.zarinpal.merchantId.trim(), merchantId: next.gateways.zarinpal.merchantId.trim(),
sandbox: next.gateways.zarinpal.sandbox,
}, },
}, },
} }
@@ -180,15 +186,6 @@ export function SettingsPage() {
return return
} }
if (
ePayment.gateways.mellat.enabled &&
!ePayment.gateways.mellat.passwordSet &&
!passwordDraft.trim()
) {
setError(t('website.ePayment.error.passwordRequired'))
return
}
await patchEPayment( await patchEPayment(
'mellat-credentials', 'mellat-credentials',
{ {
@@ -284,12 +281,18 @@ export function SettingsPage() {
<Switch <Switch
id="epayment-mellat" id="epayment-mellat"
checked={ePayment.gateways.mellat.enabled} checked={ePayment.gateways.mellat.enabled}
disabled={savingKey !== null || !ePayment.enabled} disabled={savingKey !== null}
onChange={(checked) => void handleMellatToggle(checked)} onChange={(checked) => void handleMellatToggle(checked)}
/> />
</div> </div>
<form className={styles.credentials} onSubmit={handleSaveMellatCredentials}> <form
className={`${styles.credentials} ${
!ePayment.gateways.mellat.enabled ? styles.credentialsDisabled : ''
}`}
onSubmit={handleSaveMellatCredentials}
aria-disabled={!ePayment.gateways.mellat.enabled}
>
<div className={`${fieldStyles.field} ${styles.field}`}> <div className={`${fieldStyles.field} ${styles.field}`}>
<label htmlFor="mellat-terminal"> <label htmlFor="mellat-terminal">
{t('website.ePayment.terminalId')} {t('website.ePayment.terminalId')}
@@ -301,7 +304,7 @@ export function SettingsPage() {
autoComplete="off" autoComplete="off"
dir="ltr" dir="ltr"
value={ePayment.gateways.mellat.terminalId} value={ePayment.gateways.mellat.terminalId}
disabled={savingKey !== null} disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
onChange={(e) => onChange={(e) =>
setEPayment((current) => ({ setEPayment((current) => ({
...current, ...current,
@@ -327,7 +330,7 @@ export function SettingsPage() {
autoComplete="off" autoComplete="off"
dir="ltr" dir="ltr"
value={ePayment.gateways.mellat.username} value={ePayment.gateways.mellat.username}
disabled={savingKey !== null} disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
onChange={(e) => onChange={(e) =>
setEPayment((current) => ({ setEPayment((current) => ({
...current, ...current,
@@ -346,12 +349,6 @@ export function SettingsPage() {
<div className={`${fieldStyles.field} ${styles.field}`}> <div className={`${fieldStyles.field} ${styles.field}`}>
<label htmlFor="mellat-password"> <label htmlFor="mellat-password">
{t('website.ePayment.password')} {t('website.ePayment.password')}
{ePayment.gateways.mellat.passwordSet ? (
<span className={styles.hint}>
{' '}
({t('website.ePayment.passwordSet')})
</span>
) : null}
</label> </label>
<div className={styles.passwordWrap}> <div className={styles.passwordWrap}>
<input <input
@@ -359,13 +356,9 @@ export function SettingsPage() {
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
autoComplete="new-password" autoComplete="new-password"
dir="ltr" dir="ltr"
placeholder={ placeholder={t('website.ePayment.passwordPlaceholderSet')}
ePayment.gateways.mellat.passwordSet
? t('website.ePayment.passwordPlaceholderSet')
: t('website.ePayment.passwordPlaceholder')
}
value={passwordDraft} value={passwordDraft}
disabled={savingKey !== null} disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
onChange={(e) => setPasswordDraft(e.target.value)} onChange={(e) => setPasswordDraft(e.target.value)}
/> />
<Tooltip <Tooltip
@@ -379,6 +372,7 @@ export function SettingsPage() {
type="button" type="button"
className={styles.passwordToggle} className={styles.passwordToggle}
onClick={() => setShowPassword((v) => !v)} onClick={() => setShowPassword((v) => !v)}
disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
aria-label={ aria-label={
showPassword showPassword
? t('website.ePayment.hidePassword') ? t('website.ePayment.hidePassword')
@@ -391,11 +385,11 @@ export function SettingsPage() {
</div> </div>
</div> </div>
<div className={styles.actions}> <div className={styles.actionsInline}>
<button <button
type="submit" type="submit"
className={styles.saveBtn} className={styles.saveBtn}
disabled={savingKey !== null} disabled={savingKey !== null || !ePayment.gateways.mellat.enabled}
> >
{savingKey === 'mellat-credentials' {savingKey === 'mellat-credentials'
? t('storeSettings.saving') ? t('storeSettings.saving')
@@ -418,79 +412,53 @@ export function SettingsPage() {
<Switch <Switch
id="epayment-zarinpal" id="epayment-zarinpal"
checked={ePayment.gateways.zarinpal.enabled} checked={ePayment.gateways.zarinpal.enabled}
disabled={savingKey !== null || !ePayment.enabled} disabled={savingKey !== null}
onChange={(checked) => void handleZarinpalToggle(checked)} onChange={(checked) => void handleZarinpalToggle(checked)}
/> />
</div> </div>
<form className={styles.credentials} onSubmit={handleSaveZarinpalCredentials}> <form
<div className={`${fieldStyles.field} ${styles.field}`}> className={`${styles.credentialsInline} ${
<label htmlFor="zarinpal-merchant"> !ePayment.gateways.zarinpal.enabled ? styles.credentialsDisabled : ''
{t('website.ePayment.merchantId')} }`}
</label> onSubmit={handleSaveZarinpalCredentials}
<input aria-disabled={!ePayment.gateways.zarinpal.enabled}
id="zarinpal-merchant" >
type="text" <label className={styles.inlineLabel} htmlFor="zarinpal-merchant">
autoComplete="off" {t('website.ePayment.merchantId')}
dir="ltr" </label>
spellCheck={false} <input
placeholder={t('website.ePayment.merchantIdPlaceholder')} id="zarinpal-merchant"
value={ePayment.gateways.zarinpal.merchantId} className={styles.inlineInput}
disabled={savingKey !== null} type="text"
onChange={(e) => autoComplete="off"
setEPayment((current) => ({ dir="ltr"
...current, spellCheck={false}
gateways: { placeholder={t('website.ePayment.merchantIdPlaceholder')}
...current.gateways, value={ePayment.gateways.zarinpal.merchantId}
zarinpal: { disabled={savingKey !== null || !ePayment.gateways.zarinpal.enabled}
...current.gateways.zarinpal, onChange={(e) =>
merchantId: e.target.value, setEPayment((current) => ({
}, ...current,
gateways: {
...current.gateways,
zarinpal: {
...current.gateways.zarinpal,
merchantId: e.target.value,
}, },
})) },
} }))
/> }
</div> />
<button
<div className={`${sharedStyles.row} ${styles.fullRow}`}> type="submit"
<div className={sharedStyles.rowText}> className={styles.saveBtn}
<label className={sharedStyles.rowLabel} htmlFor="zarinpal-sandbox"> disabled={savingKey !== null || !ePayment.gateways.zarinpal.enabled}
{t('website.ePayment.sandbox')} >
</label> {savingKey === 'zarinpal-credentials'
<p className={sharedStyles.rowDescription}> ? t('storeSettings.saving')
{t('website.ePayment.sandboxDesc')} : t('website.ePayment.saveCredentials')}
</p> </button>
</div>
<Switch
id="zarinpal-sandbox"
checked={ePayment.gateways.zarinpal.sandbox}
disabled={savingKey !== null}
onChange={(checked) =>
setEPayment((current) => ({
...current,
gateways: {
...current.gateways,
zarinpal: {
...current.gateways.zarinpal,
sandbox: checked,
},
},
}))
}
/>
</div>
<div className={styles.actions}>
<button
type="submit"
className={styles.saveBtn}
disabled={savingKey !== null}
>
{savingKey === 'zarinpal-credentials'
? t('storeSettings.saving')
: t('website.ePayment.saveCredentials')}
</button>
</div>
</form> </form>
</section> </section>
@@ -39,7 +39,6 @@ const DEFAULT_STORE_SETTINGS: StoreSettings = {
terminalId: '', terminalId: '',
username: '', username: '',
password: '', password: '',
passwordSet: false,
}, },
sep: { enabled: false }, sep: { enabled: false },
snappay: { enabled: false }, snappay: { enabled: false },
@@ -146,6 +146,19 @@ export async function resolveHtmlEmbeddedDataUrls(html: string): Promise<string>
return doc.body.innerHTML return doc.body.innerHTML
} }
export function galleryMediaIdsAfterChange(
previousImages: string[],
previousIds: string[],
nextImages: string[],
): string[] {
const prevUrlToId = new Map(
previousImages.map((url, index) => [url, previousIds[index] ?? '']),
)
return nextImages.map((url) =>
isDataUrl(url) ? '' : prevUrlToId.get(url) ?? '',
)
}
export async function resolveDataUrlsToMediaIds( export async function resolveDataUrlsToMediaIds(
values: string[], values: string[],
existingMediaIds: string[], existingMediaIds: string[],
@@ -31,9 +31,8 @@ export interface MellatGatewaySettings {
enabled: boolean enabled: boolean
terminalId: string terminalId: string
username: string username: string
/** Always empty in API responses; use passwordSet. */ /** Always empty in API responses; send a new value to replace the stored secret. */
password: string password: string
passwordSet?: boolean
} }
export interface ZarinpalGatewaySettings { export interface ZarinpalGatewaySettings {
@@ -73,7 +72,6 @@ export const DEFAULT_EPAYMENT_SETTINGS: EPaymentSettings = {
terminalId: '', terminalId: '',
username: '', username: '',
password: '', password: '',
passwordSet: false,
}, },
sep: { enabled: false }, sep: { enabled: false },
snappay: { enabled: false }, snappay: { enabled: false },
+3
View File
@@ -5,3 +5,6 @@ VITE_API_BASE_URL=http://localhost:3000/api/v1
# Leave unset for multi-tenant dev — tenant is resolved from hostname # Leave unset for multi-tenant dev — tenant is resolved from hostname
# (e.g. customer.sanihome.ir → sanihome.ir, customer.safeteb.com → safeteb.com). # (e.g. customer.sanihome.ir → sanihome.ir, customer.safeteb.com → safeteb.com).
# VITE_BUSINESS_DOMAIN=sanihome.ir # VITE_BUSINESS_DOMAIN=sanihome.ir
# Optional storefront origin (local Next often needs an explicit port).
# VITE_WEBSITE_ORIGIN=http://sanihome.com:3001
@@ -21,6 +21,19 @@
margin-bottom: 24px; margin-bottom: 24px;
padding: 4px 8px 12px; padding: 4px 8px 12px;
border-bottom: 1px solid rgba(148, 163, 184, 0.15); border-bottom: 1px solid rgba(148, 163, 184, 0.15);
text-decoration: none;
color: inherit;
border-radius: var(--radius-sm);
transition: background 0.15s;
cursor: pointer;
}
.brand:hover {
background: rgba(148, 163, 184, 0.1);
}
.brand:hover .brandName {
color: var(--primary);
} }
.brandLogo { .brandLogo {
@@ -109,6 +122,10 @@
transition: all 0.2s ease; transition: all 0.2s ease;
width: 100%; width: 100%;
text-align: start; text-align: start;
text-decoration: none;
background: transparent;
cursor: pointer;
box-sizing: border-box;
} }
.navItem:hover { .navItem:hover {
+22 -6
View File
@@ -1,13 +1,13 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { NavLink, useNavigate } from 'react-router-dom' import { NavLink, useNavigate } from 'react-router-dom'
import { Home, User, MapPin, ShoppingBag, Heart, Package, HelpCircle, LogOut } from 'lucide-react' import { Home, User, MapPin, ShoppingBag, Heart, Package, HelpCircle, LogOut, Globe } from 'lucide-react'
import { useLocale } from '@meshkee/dashboard-ui' import { useLocale } from '@meshkee/dashboard-ui'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import { useTenantBranding } from '../context/TenantBrandingContext' import { useTenantBranding } from '../context/TenantBrandingContext'
import { useT } from '../i18n/useT' import { useT } from '../i18n/useT'
import { getActiveBusinessDomain } from '../lib/businessContext' import { getActiveBusinessDomain } from '../lib/businessContext'
import { isAbortError } from '../lib/api' import { isAbortError } from '../lib/api'
import { getWebsiteBusinessInfo } from '../services/websiteService' import { getWebsiteBusinessInfo, getWebsiteUrl } from '../services/websiteService'
import { hasBusinessModule } from '../utils/businessModules' import { hasBusinessModule } from '../utils/businessModules'
import meshkeeLogo from '../assets/meshkee-logo.png' import meshkeeLogo from '../assets/meshkee-logo.png'
import styles from './Sidebar.module.css' import styles from './Sidebar.module.css'
@@ -26,6 +26,7 @@ export function Sidebar({ open = false }: SidebarProps) {
const [logoUrl, setLogoUrl] = useState<string | null>(null) const [logoUrl, setLogoUrl] = useState<string | null>(null)
const businessDomain = getActiveBusinessDomain() const businessDomain = getActiveBusinessDomain()
const websiteUrl = getWebsiteUrl(businessDomain)
const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback') const fallbackBusinessName = user?.customerBusinesses[0]?.name ?? t('app.storeFallback')
const displayName = brandName || fallbackBusinessName const displayName = brandName || fallbackBusinessName
const showMyProducts = hasBusinessModule(enabledModules, 'customer_products') const showMyProducts = hasBusinessModule(enabledModules, 'customer_products')
@@ -73,17 +74,23 @@ export function Sidebar({ open = false }: SidebarProps) {
id="dashboard-sidebar" id="dashboard-sidebar"
className={`${styles.sidebar} ${open ? styles.open : ''}`} className={`${styles.sidebar} ${open ? styles.open : ''}`}
> >
<div className={styles.brand}> <a
href={websiteUrl}
className={styles.brand}
target="_blank"
rel="noopener noreferrer"
aria-label={t('nav.goToWebsite')}
>
<img <img
src={logoUrl || meshkeeLogo} src={logoUrl || meshkeeLogo}
alt={displayName} alt=""
className={styles.brandLogo} className={logoUrl ? `${styles.brandLogo} ${styles.brandLogoUploaded}` : styles.brandLogo}
/> />
<div className={styles.brandText}> <div className={styles.brandText}>
<span className={styles.brandName}>{displayName}</span> <span className={styles.brandName}>{displayName}</span>
<span className={styles.brandDomain}>{businessDomain}</span> <span className={styles.brandDomain}>{businessDomain}</span>
</div> </div>
</div> </a>
<nav className={styles.nav}> <nav className={styles.nav}>
{navItems.map((item) => ( {navItems.map((item) => (
@@ -102,6 +109,15 @@ export function Sidebar({ open = false }: SidebarProps) {
</nav> </nav>
<div className={styles.footer}> <div className={styles.footer}>
<a
href={websiteUrl}
className={styles.navItem}
target="_blank"
rel="noopener noreferrer"
>
<Globe size={20} />
<span>{t('nav.goToWebsite')}</span>
</a>
<button type="button" className={styles.navItem}> <button type="button" className={styles.navItem}>
<HelpCircle size={20} /> <HelpCircle size={20} />
<span>{t('nav.help')}</span> <span>{t('nav.help')}</span>
+2
View File
@@ -16,6 +16,7 @@ const en = {
'nav.favorites': 'My Favorites', 'nav.favorites': 'My Favorites',
'nav.myProducts': 'My Products', 'nav.myProducts': 'My Products',
'nav.help': 'Help Center', 'nav.help': 'Help Center',
'nav.goToWebsite': 'Go to website',
'nav.logout': 'Logout', 'nav.logout': 'Logout',
'header.toggleMenu': 'Toggle menu', 'header.toggleMenu': 'Toggle menu',
@@ -413,6 +414,7 @@ const fa: Record<MessageKey, string> = {
'nav.favorites': 'علاقه‌مندی‌ها', 'nav.favorites': 'علاقه‌مندی‌ها',
'nav.myProducts': 'محصولات من', 'nav.myProducts': 'محصولات من',
'nav.help': 'مرکز راهنما', 'nav.help': 'مرکز راهنما',
'nav.goToWebsite': 'رفتن به وبسایت',
'nav.logout': 'خروج', 'nav.logout': 'خروج',
'header.toggleMenu': 'باز و بسته کردن منو', 'header.toggleMenu': 'باز و بسته کردن منو',
+56 -6
View File
@@ -1,20 +1,70 @@
import { createApiClient } from '@meshkee/dashboard-core' import { createApiClient } from '@meshkee/dashboard-core'
import {
clearSharedAuthCookies,
hydrateLocalTokensFromSharedCookies,
writeSharedAuthCookies,
} from './sharedAuthCookie'
const API_BASE_URL = const API_BASE_URL =
import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1' import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3000/api/v1'
export const api = createApiClient({ const ACCESS_KEY = 'meshkee_customer_access_token'
const REFRESH_KEY = 'meshkee_customer_refresh_token'
const base = createApiClient({
baseUrl: API_BASE_URL, baseUrl: API_BASE_URL,
accessTokenKey: 'meshkee_customer_access_token', accessTokenKey: ACCESS_KEY,
refreshTokenKey: 'meshkee_customer_refresh_token', refreshTokenKey: REFRESH_KEY,
}) })
export const { hydrateLocalTokensFromSharedCookies()
apiRequest,
// Keep cookies aligned with an existing localStorage session (pre-cookie logins / refresh)
function mirrorLocalTokensToCookies() {
const access = localStorage.getItem(ACCESS_KEY)
if (!access) return
writeSharedAuthCookies(access, localStorage.getItem(REFRESH_KEY) ?? undefined)
}
mirrorLocalTokensToCookies()
export function getAccessToken() {
hydrateLocalTokensFromSharedCookies()
return base.getAccessToken()
}
export function getRefreshToken() {
return base.getRefreshToken()
}
export function setTokens(accessToken: string, refreshToken: string) {
base.setTokens(accessToken, refreshToken)
writeSharedAuthCookies(accessToken, refreshToken)
}
export function clearTokens() {
base.clearTokens()
clearSharedAuthCookies()
}
export async function apiRequest<T>(
path: string,
options?: Parameters<typeof base.apiRequest<T>>[1],
): Promise<T> {
try {
return await base.apiRequest<T>(path, options)
} finally {
mirrorLocalTokensToCookies()
}
}
export const api = {
...base,
getAccessToken, getAccessToken,
getRefreshToken, getRefreshToken,
setTokens, setTokens,
clearTokens, clearTokens,
} = api apiRequest,
}
export { ApiError, isAbortError } from '@meshkee/dashboard-core' export { ApiError, isAbortError } from '@meshkee/dashboard-core'
+8 -5
View File
@@ -106,9 +106,10 @@ export function ingestGuestCartFromLocation(): GuestCartItem[] {
if (typeof window === 'undefined') return [] if (typeof window === 'undefined') return []
const hash = window.location.hash.replace(/^#/, '') const hash = window.location.hash.replace(/^#/, '')
const search = window.location.search.replace(/^\?/, '') const searchParams = new URLSearchParams(window.location.search)
const params = new URLSearchParams(hash.includes('=') ? hash : search) const hashParams = new URLSearchParams(hash.includes('=') ? hash : '')
const encoded = params.get(GUEST_CART_URL_PARAM) const encoded =
hashParams.get(GUEST_CART_URL_PARAM) || searchParams.get(GUEST_CART_URL_PARAM)
if (!encoded) return [] if (!encoded) return []
const items = decodeGuestCartFromUrl(encoded) const items = decodeGuestCartFromUrl(encoded)
@@ -116,8 +117,10 @@ export function ingestGuestCartFromLocation(): GuestCartItem[] {
saveGuestCart(items) saveGuestCart(items)
// Clean the payload from the address bar // Clean guestCart from address bar (keep other query params)
const nextUrl = `${window.location.pathname}${window.location.search}` searchParams.delete(GUEST_CART_URL_PARAM)
const nextSearch = searchParams.toString()
const nextUrl = `${window.location.pathname}${nextSearch ? `?${nextSearch}` : ''}`
window.history.replaceState(null, '', nextUrl) window.history.replaceState(null, '', nextUrl)
return items return items
+124
View File
@@ -0,0 +1,124 @@
/** Shared auth cookies so storefront + customer.{domain} share one login. */
export const SHARED_ACCESS_TOKEN_KEY = 'meshkee_customer_access_token'
export const SHARED_REFRESH_TOKEN_KEY = 'meshkee_customer_refresh_token'
const CHUNK_SIZE = 2800
const MAX_CHUNKS = 24
function cookieDomain(): string | undefined {
if (typeof window === 'undefined') return undefined
const host = window.location.hostname.toLowerCase()
if (host === 'localhost' || host === '127.0.0.1') return undefined
const parts = host.split('.').filter(Boolean)
if (parts.length < 2) return undefined
return `.${parts.slice(-2).join('.')}`
}
function readRawCookie(name: string): string | null {
if (typeof document === 'undefined') return null
const prefix = `${name}=`
const match = document.cookie.split('; ').find((row) => row.startsWith(prefix))
if (!match) return null
return match.slice(prefix.length)
}
function writeRawCookie(name: string, value: string | null) {
if (typeof document === 'undefined') return
const domain = cookieDomain()
if (value == null) {
let cookie = `${name}=; path=/; max-age=0; SameSite=Lax`
if (domain) cookie += `; domain=${domain}`
document.cookie = cookie
return
}
const maxAge = 60 * 60 * 24 * 30
let cookie = `${name}=${value}; path=/; max-age=${maxAge}; SameSite=Lax`
if (domain) cookie += `; domain=${domain}`
document.cookie = cookie
}
function clearChunkedCookie(name: string) {
writeRawCookie(name, null)
const n = Number(readRawCookie(`${name}_n`) || '0')
writeRawCookie(`${name}_n`, null)
for (let i = 0; i < Math.max(n, MAX_CHUNKS); i++) {
writeRawCookie(`${name}_${i}`, null)
}
}
function writeCookie(name: string, value: string | null) {
clearChunkedCookie(name)
if (value == null || value === '') return
const encoded = encodeURIComponent(value)
if (encoded.length <= 3500) {
writeRawCookie(name, encoded)
return
}
const chunks: string[] = []
for (let i = 0; i < encoded.length; i += CHUNK_SIZE) {
chunks.push(encoded.slice(i, i + CHUNK_SIZE))
}
writeRawCookie(`${name}_n`, String(chunks.length))
chunks.forEach((chunk, i) => writeRawCookie(`${name}_${i}`, chunk))
}
function readCookie(name: string): string | null {
const n = Number(readRawCookie(`${name}_n`) || '0')
if (n > 0) {
let encoded = ''
for (let i = 0; i < n; i++) {
const part = readRawCookie(`${name}_${i}`)
if (part == null) return null
encoded += part
}
try {
return decodeURIComponent(encoded)
} catch {
return null
}
}
const raw = readRawCookie(name)
if (raw == null) return null
try {
return decodeURIComponent(raw)
} catch {
return null
}
}
export function writeSharedAuthCookies(accessToken: string, refreshToken?: string) {
writeCookie(SHARED_ACCESS_TOKEN_KEY, accessToken)
if (refreshToken) writeCookie(SHARED_REFRESH_TOKEN_KEY, refreshToken)
}
export function clearSharedAuthCookies() {
writeCookie(SHARED_ACCESS_TOKEN_KEY, null)
writeCookie(SHARED_REFRESH_TOKEN_KEY, null)
}
export function readSharedAccessToken(): string | null {
return readCookie(SHARED_ACCESS_TOKEN_KEY)
}
export function readSharedRefreshToken(): string | null {
return readCookie(SHARED_REFRESH_TOKEN_KEY)
}
/** Copy shared cookies into localStorage when this origin has no tokens yet. */
export function hydrateLocalTokensFromSharedCookies(
accessKey = SHARED_ACCESS_TOKEN_KEY,
refreshKey = SHARED_REFRESH_TOKEN_KEY,
): boolean {
if (typeof window === 'undefined') return false
if (localStorage.getItem(accessKey)) return false
const access = readSharedAccessToken()
if (!access) return false
localStorage.setItem(accessKey, access)
const refresh = readSharedRefreshToken()
if (refresh) localStorage.setItem(refreshKey, refresh)
return true
}
+13 -2
View File
@@ -16,7 +16,18 @@ export async function getWebsiteBusinessInfo(host: string, signal?: AbortSignal)
}) })
} }
/** Public storefront URL for the tenant (e.g. https://sanihome.ir). */ /** Public storefront URL for the tenant (e.g. https://sanihome.com). */
export function getWebsiteUrl(domain = getTenantDomain()) { export function getWebsiteUrl(domain = getTenantDomain()) {
return `${window.location.protocol}//${domain}` const configured = (import.meta.env.VITE_WEBSITE_ORIGIN as string | undefined)?.replace(
/\/$/,
'',
)
if (configured) return configured
const { protocol, port } = window.location
// Local customer app is :5175; Sanihome Next storefront runs on :3001
if (import.meta.env.DEV && (port === '5175' || port === '')) {
return `${protocol}//${domain}:3001`
}
return `${protocol}//${domain}`
} }