Add Website settings for special-products source.

Businesses can choose product vs store item sourcing, with store as the default when the store module is enabled, and rename the FA special-products section.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-21 11:57:49 +03:30
co-authored by Cursor
parent a7950f1f13
commit 193c63bfd2
10 changed files with 286 additions and 9 deletions
+8
View File
@@ -5,6 +5,7 @@ import {
Award,
Image,
Images,
Settings,
Sparkles,
} from 'lucide-react'
import { SectionCard } from '../components/SectionCard'
@@ -63,6 +64,13 @@ const websiteSections: {
linkKey: 'website.card.badges.link',
href: '/website/badges',
},
{
icon: Settings,
titleKey: 'nav.website.settings',
descKey: 'website.card.settings.desc',
linkKey: 'website.card.settings.link',
href: '/website/settings',
},
]
export function WebsitePage() {
@@ -0,0 +1,52 @@
.sourceRow {
align-items: flex-start;
}
.radioGroup {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 200px;
flex-shrink: 0;
}
.radioOption {
display: flex;
align-items: center;
gap: 8px;
min-height: var(--field-height);
padding: var(--field-padding-y) var(--field-padding-x);
font-size: var(--field-font-size);
line-height: 1.4;
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);
cursor: pointer;
transition: border-color 0.2s, box-shadow 0.2s;
}
.radioOption:has(input:checked) {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(var(--primary-rgb) / 0.12);
}
.radioOption:has(input:disabled) {
opacity: 0.6;
cursor: not-allowed;
}
.radioOption input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--primary);
flex-shrink: 0;
}
@media (max-width: 768px) {
.radioGroup {
width: 100%;
min-width: 0;
}
}
@@ -0,0 +1,160 @@
import { useEffect, useState } from 'react'
import { Breadcrumbs } from '../components/Breadcrumbs'
import { PageTitle } from '../components/PageTitle'
import { useToast } from '../context/ToastContext'
import { useT } from '../i18n/useT'
import { ApiError } from '../lib/api'
import {
getSettings,
updateWebsiteSettings,
type SpecialProductsSource,
} from '../services/settingsService'
import { hasBusinessModule, type BusinessModuleId } from '../utils/businessModules'
import pageStyles from '../components/PageContent.module.css'
import sharedStyles from './ProductSettingsPage.module.css'
import styles from './WebsiteSettingsPage.module.css'
const SOURCE_OPTIONS: SpecialProductsSource[] = ['product', 'store_item']
function defaultSpecialProductsSource(
enabledModules: string[] | undefined,
): SpecialProductsSource {
return hasBusinessModule(
enabledModules as BusinessModuleId[] | undefined,
'store',
)
? 'store_item'
: 'product'
}
export function WebsiteSettingsPage() {
const t = useT()
const { showToast } = useToast()
const [source, setSource] = useState<SpecialProductsSource>('product')
const [isLoading, setIsLoading] = useState(true)
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
void loadSettings(controller.signal)
return () => controller.abort()
}, [])
async function loadSettings(signal?: AbortSignal) {
setIsLoading(true)
setError('')
try {
const data = await getSettings(signal)
setSource(
data.settings.website?.specialProductsSource ??
defaultSpecialProductsSource(data.settings.modules?.enabled),
)
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.settings.errorLoad'))
}
} finally {
setIsLoading(false)
}
}
async function handleSourceChange(next: SpecialProductsSource) {
if (next === source || isSaving) return
const previous = source
setSource(next)
setIsSaving(true)
setError('')
try {
const data = await updateWebsiteSettings({ specialProductsSource: next })
setSource(
data.settings.website?.specialProductsSource ?? next,
)
showToast(t('website.settings.saved'), 'success')
} catch (err) {
setSource(previous)
if (err instanceof ApiError) {
setError(err.message)
} else {
setError(t('website.settings.errorSave'))
}
} finally {
setIsSaving(false)
}
}
return (
<main className={pageStyles.content}>
<Breadcrumbs
items={[
{ label: 'Dashboard', href: '/' },
{ label: 'Website', href: '/website' },
{ label: 'Settings' },
]}
/>
<div className={pageStyles.pageHeader}>
<div>
<PageTitle en="WEBSITE SETTINGS">{t('website.settings.title')}</PageTitle>
<p className={pageStyles.pageSubtitle}>{t('website.settings.subtitle')}</p>
</div>
</div>
{error && (
<div className={sharedStyles.alertError} role="alert">
{error}
</div>
)}
<section className={sharedStyles.panel}>
<h3 className={sharedStyles.sectionTitle}>{t('website.settings.section')}</h3>
{isLoading ? (
<p className={sharedStyles.status}>{t('website.settings.loading')}</p>
) : (
<div className={sharedStyles.list}>
<div className={`${sharedStyles.row} ${styles.sourceRow}`}>
<div className={sharedStyles.rowText}>
<span className={sharedStyles.rowLabel} id="special-products-source-label">
{t('website.settings.specialSource')}
</span>
<p className={sharedStyles.rowDescription}>
{t('website.settings.specialSourceDesc')}
</p>
</div>
<div
className={styles.radioGroup}
role="radiogroup"
aria-labelledby="special-products-source-label"
>
{SOURCE_OPTIONS.map((option) => (
<label key={option} className={styles.radioOption}>
<input
type="radio"
name="special-products-source"
value={option}
checked={source === option}
disabled={isSaving}
onChange={() => void handleSourceChange(option)}
/>
<span>
{option === 'product'
? t('website.settings.source.product')
: t('website.settings.source.storeItem')}
</span>
</label>
))}
</div>
</div>
</div>
)}
</section>
</main>
)
}