feat: implement AI styling foundation and billing scope rework
This commit is contained in:
@@ -11,9 +11,11 @@ import { ScrollView } from '@tamagui/scroll-view';
|
||||
import { ToggleGroup } from '@tamagui/toggle-group';
|
||||
import { MobileShell, HeaderActionButton } from './components/MobileShell';
|
||||
import { MobileCard, CTAButton, SkeletonCard, ContentTabs } from './components/Primitives';
|
||||
import { MobileField, MobileSelect } from './components/FormControls';
|
||||
import { MobileField, MobileSelect, MobileTextArea } from './components/FormControls';
|
||||
import { useEventContext } from '../context/EventContext';
|
||||
import {
|
||||
AiEditStyle,
|
||||
AiEditUsageSummary,
|
||||
approveAndLiveShowPhoto,
|
||||
approveLiveShowPhoto,
|
||||
clearLiveShowPhoto,
|
||||
@@ -21,9 +23,12 @@ import {
|
||||
ControlRoomUploaderRule,
|
||||
createEventAddonCheckout,
|
||||
EventAddonCatalogItem,
|
||||
EventAiEditingSettings,
|
||||
EventLimitSummary,
|
||||
getAddonCatalog,
|
||||
featurePhoto,
|
||||
getEventAiEditSummary,
|
||||
getEventAiStyles,
|
||||
getEventPhotos,
|
||||
getEvents,
|
||||
getLiveShowQueue,
|
||||
@@ -75,6 +80,20 @@ const LIVE_STATUS_OPTIONS: Array<{ value: LiveShowQueueStatus; labelKey: string;
|
||||
{ value: 'none', labelKey: 'liveShowQueue.statusNone', fallback: 'Not queued' },
|
||||
];
|
||||
|
||||
type AiSettingsDraft = {
|
||||
enabled: boolean;
|
||||
allow_custom_prompt: boolean;
|
||||
allowed_style_keys: string[];
|
||||
policy_message: string;
|
||||
};
|
||||
|
||||
const DEFAULT_AI_SETTINGS: AiSettingsDraft = {
|
||||
enabled: true,
|
||||
allow_custom_prompt: true,
|
||||
allowed_style_keys: [],
|
||||
policy_message: '',
|
||||
};
|
||||
|
||||
type LimitTranslator = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
function translateLimits(t: (key: string, defaultValue?: string, options?: Record<string, unknown>) => string): LimitTranslator {
|
||||
@@ -280,6 +299,53 @@ function formatDeviceId(deviceId: string): string {
|
||||
return `${deviceId.slice(0, 4)}...${deviceId.slice(-4)}`;
|
||||
}
|
||||
|
||||
function normalizeAiSettingsDraft(
|
||||
source: EventAiEditingSettings | null | undefined,
|
||||
fallbackAllowedStyleKeys: string[] = [],
|
||||
): AiSettingsDraft {
|
||||
return {
|
||||
enabled: source?.enabled === undefined ? true : Boolean(source.enabled),
|
||||
allow_custom_prompt:
|
||||
source?.allow_custom_prompt === undefined ? true : Boolean(source.allow_custom_prompt),
|
||||
allowed_style_keys:
|
||||
Array.isArray(source?.allowed_style_keys) && source?.allowed_style_keys.length
|
||||
? source.allowed_style_keys.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
: fallbackAllowedStyleKeys,
|
||||
policy_message: typeof source?.policy_message === 'string' ? source.policy_message : '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeAiStyleKeyList(keys: string[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
keys
|
||||
.filter((value) => typeof value === 'string')
|
||||
.map((value) => value.trim())
|
||||
.filter((value) => value.length > 0),
|
||||
),
|
||||
).sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function isAiSettingsDirty(current: AiSettingsDraft, initial: AiSettingsDraft): boolean {
|
||||
if (current.enabled !== initial.enabled) {
|
||||
return true;
|
||||
}
|
||||
if (current.allow_custom_prompt !== initial.allow_custom_prompt) {
|
||||
return true;
|
||||
}
|
||||
if ((current.policy_message ?? '').trim() !== (initial.policy_message ?? '').trim()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const currentKeys = normalizeAiStyleKeyList(current.allowed_style_keys);
|
||||
const initialKeys = normalizeAiStyleKeyList(initial.allowed_style_keys);
|
||||
if (currentKeys.length !== initialKeys.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return currentKeys.some((key, index) => key !== initialKeys[index]);
|
||||
}
|
||||
|
||||
export default function MobileEventControlRoomPage() {
|
||||
const { slug: slugParam } = useParams<{ slug?: string }>();
|
||||
const navigate = useNavigate();
|
||||
@@ -312,7 +378,7 @@ export default function MobileEventControlRoomPage() {
|
||||
const [catalogAddons, setCatalogAddons] = React.useState<EventAddonCatalogItem[]>([]);
|
||||
const [busyScope, setBusyScope] = React.useState<string | null>(null);
|
||||
const [consentOpen, setConsentOpen] = React.useState(false);
|
||||
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests'; addonKey: string } | null>(null);
|
||||
const [consentTarget, setConsentTarget] = React.useState<{ scope: 'photos' | 'gallery' | 'guests' | 'ai'; addonKey: string } | null>(null);
|
||||
const [consentBusy, setConsentBusy] = React.useState(false);
|
||||
|
||||
const [livePhotos, setLivePhotos] = React.useState<TenantPhoto[]>([]);
|
||||
@@ -347,6 +413,31 @@ export default function MobileEventControlRoomPage() {
|
||||
const autoRemoveLiveOnHide = Boolean(controlRoomSettings.auto_remove_live_on_hide);
|
||||
const trustedUploaders = controlRoomSettings.trusted_uploaders ?? [];
|
||||
const forceReviewUploaders = controlRoomSettings.force_review_uploaders ?? [];
|
||||
const aiCapabilities = (activeEvent?.capabilities ?? null) as
|
||||
| {
|
||||
ai_styling?: boolean;
|
||||
ai_styling_addon_keys?: string[] | null;
|
||||
}
|
||||
| null;
|
||||
const aiStylingEntitled = Boolean(aiCapabilities?.ai_styling);
|
||||
const aiStylingAddonKeys =
|
||||
Array.isArray(aiCapabilities?.ai_styling_addon_keys) && aiCapabilities?.ai_styling_addon_keys.length
|
||||
? aiCapabilities.ai_styling_addon_keys
|
||||
: ['ai_styling_unlock'];
|
||||
const [aiStyles, setAiStyles] = React.useState<AiEditStyle[]>([]);
|
||||
const [aiUsageSummary, setAiUsageSummary] = React.useState<AiEditUsageSummary | null>(null);
|
||||
const [aiSettingsDraft, setAiSettingsDraft] = React.useState<AiSettingsDraft>(DEFAULT_AI_SETTINGS);
|
||||
const [initialAiSettingsDraft, setInitialAiSettingsDraft] = React.useState<AiSettingsDraft>(DEFAULT_AI_SETTINGS);
|
||||
const [aiSettingsLoading, setAiSettingsLoading] = React.useState(false);
|
||||
const [aiSettingsSaving, setAiSettingsSaving] = React.useState(false);
|
||||
const [aiSettingsError, setAiSettingsError] = React.useState<string | null>(null);
|
||||
const aiStylingAddon = React.useMemo(() => {
|
||||
return catalogAddons.find((addon) => addon.price_id && aiStylingAddonKeys.includes(addon.key)) ?? null;
|
||||
}, [aiStylingAddonKeys, catalogAddons]);
|
||||
const aiSettingsDirty = React.useMemo(
|
||||
() => isAiSettingsDirty(aiSettingsDraft, initialAiSettingsDraft),
|
||||
[aiSettingsDraft, initialAiSettingsDraft],
|
||||
);
|
||||
|
||||
const [queuedActions, setQueuedActions] = React.useState<PhotoModerationAction[]>(() => loadPhotoQueue());
|
||||
const [syncingQueue, setSyncingQueue] = React.useState(false);
|
||||
@@ -388,6 +479,99 @@ export default function MobileEventControlRoomPage() {
|
||||
[controlRoomSettings, refetch, slug, t],
|
||||
);
|
||||
|
||||
const loadAiSettings = React.useCallback(async () => {
|
||||
if (!slug || !aiStylingEntitled) {
|
||||
setAiStyles([]);
|
||||
setAiUsageSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setAiSettingsLoading(true);
|
||||
setAiSettingsError(null);
|
||||
|
||||
try {
|
||||
const [stylesResult, usageResult] = await Promise.all([
|
||||
getEventAiStyles(slug),
|
||||
getEventAiEditSummary(slug),
|
||||
]);
|
||||
|
||||
setAiStyles(stylesResult.styles);
|
||||
setAiUsageSummary(usageResult);
|
||||
|
||||
if (stylesResult.meta.allowed_style_keys && stylesResult.meta.allowed_style_keys.length) {
|
||||
setAiSettingsDraft((previous) => ({
|
||||
...previous,
|
||||
allowed_style_keys: stylesResult.meta.allowed_style_keys ?? previous.allowed_style_keys,
|
||||
}));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isAuthError(err)) {
|
||||
setAiSettingsError(
|
||||
getApiErrorMessage(
|
||||
err,
|
||||
t('controlRoom.aiSettings.loadFailed', 'AI settings could not be loaded.')
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
setAiSettingsLoading(false);
|
||||
}
|
||||
}, [aiStylingEntitled, slug, t]);
|
||||
|
||||
const saveAiSettings = React.useCallback(async () => {
|
||||
if (!slug || !aiStylingEntitled || !aiSettingsDirty) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: EventAiEditingSettings = {
|
||||
enabled: aiSettingsDraft.enabled,
|
||||
allow_custom_prompt: aiSettingsDraft.allow_custom_prompt,
|
||||
allowed_style_keys: normalizeAiStyleKeyList(aiSettingsDraft.allowed_style_keys),
|
||||
policy_message: aiSettingsDraft.policy_message.trim() || null,
|
||||
};
|
||||
|
||||
setAiSettingsSaving(true);
|
||||
setAiSettingsError(null);
|
||||
try {
|
||||
await updateEvent(slug, {
|
||||
settings: {
|
||||
ai_editing: payload,
|
||||
},
|
||||
});
|
||||
setInitialAiSettingsDraft({
|
||||
...aiSettingsDraft,
|
||||
allowed_style_keys: payload.allowed_style_keys ?? [],
|
||||
policy_message: payload.policy_message ?? '',
|
||||
});
|
||||
await loadAiSettings();
|
||||
refetch();
|
||||
toast.success(t('controlRoom.aiSettings.saved', 'AI settings saved.'));
|
||||
} catch (err) {
|
||||
setAiSettingsError(
|
||||
getApiErrorMessage(
|
||||
err,
|
||||
t('controlRoom.aiSettings.saveFailed', 'AI settings could not be saved.')
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setAiSettingsSaving(false);
|
||||
}
|
||||
}, [aiSettingsDirty, aiSettingsDraft, aiStylingEntitled, loadAiSettings, refetch, slug, t]);
|
||||
|
||||
const toggleAiStyleKey = React.useCallback((styleKey: string) => {
|
||||
setAiSettingsDraft((previous) => {
|
||||
const hasKey = previous.allowed_style_keys.includes(styleKey);
|
||||
const next = hasKey
|
||||
? previous.allowed_style_keys.filter((key) => key !== styleKey)
|
||||
: [...previous.allowed_style_keys, styleKey];
|
||||
|
||||
return {
|
||||
...previous,
|
||||
allowed_style_keys: normalizeAiStyleKeyList(next),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const uploaderOptions = React.useMemo(() => {
|
||||
const options = new Map<string, { deviceId: string; label: string }>();
|
||||
const addPhoto = (photo: TenantPhoto) => {
|
||||
@@ -503,6 +687,21 @@ export default function MobileEventControlRoomPage() {
|
||||
setForceReviewSelection('');
|
||||
}, [activeEvent?.settings?.control_room, activeEvent?.slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const eventSettings = (activeEvent?.settings?.ai_editing ?? null) as EventAiEditingSettings | null;
|
||||
const capabilityStyleKeys = Array.isArray(aiCapabilities?.ai_styling_allowed_style_keys)
|
||||
? aiCapabilities.ai_styling_allowed_style_keys
|
||||
: [];
|
||||
const next = normalizeAiSettingsDraft(eventSettings, capabilityStyleKeys);
|
||||
setAiSettingsDraft(next);
|
||||
setInitialAiSettingsDraft(next);
|
||||
setAiSettingsError(null);
|
||||
}, [
|
||||
activeEvent?.settings?.ai_editing,
|
||||
activeEvent?.slug,
|
||||
aiCapabilities?.ai_styling_allowed_style_keys,
|
||||
]);
|
||||
|
||||
const ensureSlug = React.useCallback(async () => {
|
||||
if (slug) {
|
||||
return slug;
|
||||
@@ -533,6 +732,16 @@ export default function MobileEventControlRoomPage() {
|
||||
setLivePage(1);
|
||||
}, [liveStatusFilter, slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug || !aiStylingEntitled) {
|
||||
setAiStyles([]);
|
||||
setAiUsageSummary(null);
|
||||
return;
|
||||
}
|
||||
|
||||
loadAiSettings();
|
||||
}, [aiStylingEntitled, loadAiSettings, slug]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeTab === 'moderation') {
|
||||
moderationResetRef.current = true;
|
||||
@@ -906,10 +1115,12 @@ export default function MobileEventControlRoomPage() {
|
||||
],
|
||||
);
|
||||
|
||||
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||
function resolveScopeAndAddonKey(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
|
||||
const scope =
|
||||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||||
? scopeOrKey
|
||||
: scopeOrKey === 'ai' || scopeOrKey.includes('ai')
|
||||
? 'ai'
|
||||
: scopeOrKey.includes('gallery')
|
||||
? 'gallery'
|
||||
: scopeOrKey.includes('guest')
|
||||
@@ -918,16 +1129,18 @@ export default function MobileEventControlRoomPage() {
|
||||
|
||||
const addonKey =
|
||||
scopeOrKey === 'photos' || scopeOrKey === 'gallery' || scopeOrKey === 'guests'
|
||||
? selectAddonKeyForScope(catalogAddons, scope)
|
||||
? selectAddonKeyForScope(catalogAddons, scope as 'photos' | 'gallery' | 'guests')
|
||||
: scopeOrKey === 'ai'
|
||||
? aiStylingAddon?.key ?? 'ai_styling_unlock'
|
||||
: scopeOrKey;
|
||||
|
||||
return { scope, addonKey };
|
||||
}
|
||||
|
||||
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | string) {
|
||||
function startAddonCheckout(scopeOrKey: 'photos' | 'gallery' | 'guests' | 'ai' | string) {
|
||||
if (!slug) return;
|
||||
const { scope, addonKey } = resolveScopeAndAddonKey(scopeOrKey);
|
||||
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests', addonKey });
|
||||
setConsentTarget({ scope: scope as 'photos' | 'gallery' | 'guests' | 'ai', addonKey });
|
||||
setConsentOpen(true);
|
||||
}
|
||||
|
||||
@@ -1433,6 +1646,195 @@ export default function MobileEventControlRoomPage() {
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
|
||||
{!moderationLoading ? (
|
||||
aiStylingEntitled ? (
|
||||
<MobileCard gap="$3" borderColor={border} backgroundColor={surface}>
|
||||
<XStack alignItems="center" justifyContent="space-between" gap="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color={primary} />
|
||||
<Text fontSize="$md" fontWeight="800" color={textStrong}>
|
||||
{t('controlRoom.aiSettings.title', 'AI Styling Controls')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<CTAButton
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
disabled={aiSettingsLoading || aiSettingsSaving}
|
||||
label={t('common.refresh', 'Refresh')}
|
||||
onPress={() => loadAiSettings()}
|
||||
/>
|
||||
</XStack>
|
||||
<Text fontSize="$sm" color={muted}>
|
||||
{t(
|
||||
'controlRoom.aiSettings.subtitle',
|
||||
'Enable AI edits per event, configure allowed presets, and monitor usage/failures.'
|
||||
)}
|
||||
</Text>
|
||||
|
||||
{aiSettingsError ? (
|
||||
<Text fontSize="$sm" fontWeight="700" color={danger}>
|
||||
{aiSettingsError}
|
||||
</Text>
|
||||
) : null}
|
||||
|
||||
<MobileField
|
||||
label={t('controlRoom.aiSettings.enabled.label', 'Enable AI edits for this event')}
|
||||
hint={t(
|
||||
'controlRoom.aiSettings.enabled.hint',
|
||||
'When disabled, guest and admin AI requests are blocked for this event.'
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
size="$3"
|
||||
checked={aiSettingsDraft.enabled}
|
||||
disabled={aiSettingsSaving}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setAiSettingsDraft((previous) => ({ ...previous, enabled: Boolean(checked) }))
|
||||
}
|
||||
aria-label={t('controlRoom.aiSettings.enabled.label', 'Enable AI edits for this event')}
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
</MobileField>
|
||||
|
||||
<MobileField
|
||||
label={t('controlRoom.aiSettings.customPrompt.label', 'Allow custom prompts')}
|
||||
hint={t(
|
||||
'controlRoom.aiSettings.customPrompt.hint',
|
||||
'If disabled, users must choose one of the allowed presets.'
|
||||
)}
|
||||
>
|
||||
<Switch
|
||||
size="$3"
|
||||
checked={aiSettingsDraft.allow_custom_prompt}
|
||||
disabled={aiSettingsSaving}
|
||||
onCheckedChange={(checked: boolean) =>
|
||||
setAiSettingsDraft((previous) => ({ ...previous, allow_custom_prompt: Boolean(checked) }))
|
||||
}
|
||||
aria-label={t('controlRoom.aiSettings.customPrompt.label', 'Allow custom prompts')}
|
||||
>
|
||||
<Switch.Thumb />
|
||||
</Switch>
|
||||
</MobileField>
|
||||
|
||||
<MobileField
|
||||
label={t('controlRoom.aiSettings.policyMessage.label', 'Policy message')}
|
||||
hint={t(
|
||||
'controlRoom.aiSettings.policyMessage.hint',
|
||||
'Shown to users when AI edits are disabled or a style is blocked.'
|
||||
)}
|
||||
>
|
||||
<MobileTextArea
|
||||
value={aiSettingsDraft.policy_message}
|
||||
maxLength={280}
|
||||
placeholder={t('controlRoom.aiSettings.policyMessage.placeholder', 'Optional message for guests/admins')}
|
||||
onChange={(event) =>
|
||||
setAiSettingsDraft((previous) => ({ ...previous, policy_message: event.target.value }))
|
||||
}
|
||||
/>
|
||||
</MobileField>
|
||||
|
||||
<MobileField
|
||||
label={t('controlRoom.aiSettings.styles.label', 'Allowed AI styles')}
|
||||
hint={t(
|
||||
'controlRoom.aiSettings.styles.hint',
|
||||
'No selection means all active styles are allowed.'
|
||||
)}
|
||||
>
|
||||
{aiSettingsLoading ? (
|
||||
<SkeletonCard height={78} />
|
||||
) : aiStyles.length === 0 ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('controlRoom.aiSettings.styles.empty', 'No active AI styles found.')}
|
||||
</Text>
|
||||
) : (
|
||||
<YStack gap="$2">
|
||||
<XStack flexWrap="wrap" gap="$2">
|
||||
{aiStyles.map((style) => {
|
||||
const selected = aiSettingsDraft.allowed_style_keys.includes(style.key);
|
||||
return (
|
||||
<Pressable
|
||||
key={style.id}
|
||||
onPress={() => toggleAiStyleKey(style.key)}
|
||||
disabled={aiSettingsSaving}
|
||||
aria-label={style.name}
|
||||
style={{
|
||||
padding: '7px 12px',
|
||||
borderRadius: 999,
|
||||
borderWidth: 1,
|
||||
borderStyle: 'solid',
|
||||
borderColor: selected ? activePillBorder : border,
|
||||
backgroundColor: selected ? activePillBg : 'transparent',
|
||||
opacity: aiSettingsSaving ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$xs" fontWeight={selected ? '700' : '600'} color={selected ? text : muted}>
|
||||
{style.name}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</XStack>
|
||||
<CTAButton
|
||||
tone="ghost"
|
||||
fullWidth={false}
|
||||
disabled={aiSettingsSaving || aiSettingsDraft.allowed_style_keys.length === 0}
|
||||
label={t('controlRoom.aiSettings.styles.clear', 'Allow all styles')}
|
||||
onPress={() =>
|
||||
setAiSettingsDraft((previous) => ({ ...previous, allowed_style_keys: [] }))
|
||||
}
|
||||
/>
|
||||
</YStack>
|
||||
)}
|
||||
</MobileField>
|
||||
|
||||
{aiUsageSummary ? (
|
||||
<MobileCard borderColor={border} backgroundColor={surfaceMuted}>
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$sm" fontWeight="800" color={text}>
|
||||
{t('controlRoom.aiSettings.usage.title', 'Usage overview')}
|
||||
</Text>
|
||||
<XStack gap="$3" flexWrap="wrap">
|
||||
<YStack minWidth={88}>
|
||||
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.total', 'Total')}</Text>
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>{aiUsageSummary.total}</Text>
|
||||
</YStack>
|
||||
<YStack minWidth={88}>
|
||||
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.succeeded', 'Succeeded')}</Text>
|
||||
<Text fontSize="$lg" fontWeight="800" color={textStrong}>
|
||||
{aiUsageSummary.status_counts.succeeded ?? 0}
|
||||
</Text>
|
||||
</YStack>
|
||||
<YStack minWidth={88}>
|
||||
<Text fontSize={10} color={muted}>{t('controlRoom.aiSettings.usage.failed', 'Failed')}</Text>
|
||||
<Text fontSize="$lg" fontWeight="800" color={danger}>{aiUsageSummary.failed_total}</Text>
|
||||
</YStack>
|
||||
</XStack>
|
||||
{aiUsageSummary.last_requested_at ? (
|
||||
<Text fontSize="$xs" color={muted}>
|
||||
{t('controlRoom.aiSettings.usage.lastRequest', 'Last request: {{date}}', {
|
||||
date: new Date(aiUsageSummary.last_requested_at).toLocaleString(),
|
||||
})}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</MobileCard>
|
||||
) : null}
|
||||
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
<CTAButton
|
||||
tone="primary"
|
||||
fullWidth={false}
|
||||
disabled={!aiSettingsDirty || aiSettingsSaving}
|
||||
loading={aiSettingsSaving}
|
||||
label={t('controlRoom.aiSettings.save', 'Save AI settings')}
|
||||
onPress={() => saveAiSettings()}
|
||||
/>
|
||||
</XStack>
|
||||
</MobileCard>
|
||||
) : null
|
||||
) : null}
|
||||
|
||||
{!moderationLoading ? (
|
||||
<LimitWarnings
|
||||
limits={limits}
|
||||
|
||||
Reference in New Issue
Block a user