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:
co-authored by
Cursor
parent
c289de5d2e
commit
dc570c1e7f
@@ -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>
|
||||||
|
|||||||
@@ -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}`}>
|
||||||
|
|||||||
@@ -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}`}>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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': 'باز و بسته کردن منو',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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}`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user